Part III · RL for LLMs · Chapter 20

RL systems for LLMs

Why rollouts dominate cost, async and off-policy training, train–inference mismatch, and the open-source stacks.

~120 minBefore this: PPO, RLVR & GRPOUpdated 2026-10

In Chapter 15 you wrote GRPO in about forty lines of PyTorch, and it trained a tiny model on your laptop in three minutes. Now someone hands you 64 GPUs and a 7-billion-parameter model and asks for the same thing. You launch it and watch the GPU monitor. Most of the time, most of the GPUs are doing nothing. They are waiting for one response, out of hundreds, that is still writing its 30,000th token.

You fix that by letting generation run ahead of training. The run gets three times faster. Then, a few hundred steps in, the reward collapses for no visible reason. The cause, it turns out, is that the program that generated the tokens and the program that trained on them computed slightly different probabilities for the same tokens with the same weights, and your “on-policy” algorithm had been quietly off-policy all along.

Both surprises are what this chapter is about. At scale, RL for LLMs is as much a systems problem as an algorithms problem, and the systems choices leak into the math: they decide how stale your data is, which importance ratios you need, and which samples you silently throw away. You will see where the time and memory go, why generation dominates, how synchronous and asynchronous designs trade speed for staleness, and how the community found and fixed the train–inference mismatch in 2025. Along the way, ideas from Part II come back: off-policy learning (Chapter 5), importance sampling and trust regions (Chapter 9), and control variates (Chapter 7).

One RL step, seen by the hardware

Here is one step of GRPO or PPO as the hardware sees it. Nothing in it is new. What is new is that each line runs on different software, often on different machines.

repeat:
  1. GENERATE  sample G responses for each of B prompts with the current policy     (inference engine)
  2. SCORE     reward each response: verifier, unit tests in a sandbox, or a reward model   (reward workers)
  3. PREPARE   log-probs of every sampled token under the old policy and the reference;
               values from the critic (PPO only); advantages                         (trainer, forward passes)
  4. TRAIN     a few gradient steps on the clipped objective                          (trainer)
  5. SYNC      copy the new weights into the inference engine                         (network or memory copy)

Each stage has its own specialized software:

ComponentJobTypical software
Inference engineGenerates tokens fast: batching, KV-cache management, fused kernelsvLLM, SGLang
TrainerForward and backward passes, optimizer, sharding the model across GPUsPyTorch FSDP, DeepSpeed, Megatron-LM
Reward workersRun verifiers, execute code in sandboxes, call reward models or LLM judgesCPU pools, containers, GPU servers
ControllerMoves prompts, responses, rewards and weights between the othersRay, custom schedulers

Why not use one program for everything? Because generating text and training on text are different workloads, and the code that is fastest for one is slow for the other. The next section shows why.

Why generation dominates

Training is parallel, generation is not

When the trainer computes log⁡πθ(yt∣x,y<t)\log \pi_\theta(y_t \mid x, y_{\lt t}) for a 2,000-token response, it already knows all 2,000 tokens. One forward pass handles them all at once (this is teacher forcing). The GPU multiplies large matrices, which is what it is built for.

Generation can’t do that. Token 1,001 depends on token 1,000, which the model has not written yet. So the engine runs 2,000 forward passes in a row, each producing a single token per sequence. Each pass must read every weight of the model from GPU memory, and then does only a little arithmetic with each one.

A worked example: the memory-bandwidth wall

Take a 7-billion-parameter model in bf16 (2 bytes per parameter), so 14 GB of weights, on an NVIDIA H100 SXM. The H100 can read memory at about 3.35 TB/s and do about 989 trillion bf16 operations per second (dense).

One decode step at batch size 1. Reading 14 GB at 3.35 TB/s takes

14×109 bytes3.35×1012 bytes/s≈4.2 ms.\frac{14 \times 10^{9}\ \text{bytes}}{3.35 \times 10^{12}\ \text{bytes/s}} \approx 4.2\ \text{ms}.

So one sequence gets at most about 240 tokens per second, no matter how fast the arithmetic units are. And the arithmetic needed is small: a forward pass costs about 2 operations per parameter per token, 2×7×109=1.4×10102 \times 7\times10^9 = 1.4\times 10^{10} operations, which the H100 could do in 0.014 ms. The arithmetic units sit idle about 99.7% of the time. Decode at batch size 1 is memory-bandwidth-bound.

The fix is batching. If 64 sequences decode together, the weights are read once per step and used 64 times. The step still takes about 4.2 ms, but now produces 64 tokens. The step only becomes limited by arithmetic when the batch is large enough that the math takes as long as the memory read:

b×1.4×1010 operations≈989×1012 ops/s×4.2 ms⇒b≈300.b \times 1.4\times10^{10}\ \text{operations} \approx 989 \times 10^{12}\ \text{ops/s} \times 4.2\ \text{ms} \quad\Rightarrow\quad b \approx 300.

So you want hundreds of sequences in flight at once. What stops you is memory, and specifically the KV cache.

The KV cache

During decode, attention at step tt needs the keys and values of all earlier tokens. Recomputing them every step would be wasteful, so the engine stores them: the KV cache. Its size per token is

KV bytes per token=2×nlayers×nkv heads×dhead×bytes per value,\text{KV bytes per token} = 2 \times n_\text{layers} \times n_\text{kv heads} \times d_\text{head} \times \text{bytes per value},

where the 2 counts one key and one value. For Qwen2.5-7B (28 layers, 4 KV heads of dimension 128, bf16) that is 2×28×4×128×2=57,3442 \times 28 \times 4 \times 128 \times 2 = 57{,}344 bytes, 56 KiB per token. That sounds small until you multiply:

TokensKV cache
One 16k-token reasoning response16,3840.94 GB
64 such responses1,048,57660 GB

The weights of that model take about 15 GB (Qwen2.5-7B actually has 7.6 billion parameters, not exactly 7). Weights plus 60 GB of KV cache almost fill an 80 GB H100. So at this response length, memory caps the batch at about 64, well short of the 300 you wanted. And the KV cache also has to be read every decode step. Near the end of those 16k-token responses, each step reads 15 GB of weights plus 60 GB of cache, about 22 ms, giving roughly 64/0.022≈2,80064 / 0.022 \approx 2{,}800 tokens per second. At 1,000 tokens of context the same GPU manages about 11,000. Long chains of thought make every token slower, not just more numerous.

The long tail

Response lengths in reasoning RL are wildly uneven. Most responses to a math problem might be 2,000 tokens, while a few run to the 32,000-token limit. In a synchronous step, the step ends when the last response ends. As the batch drains, fewer and fewer sequences are left in flight, and since a decode step costs about the same whether it serves 64 sequences or 3, the GPU spends the tail of the step doing a few percent of its possible work.

Here is how bad it gets. Suppose lengths are exponentially distributed with mean mm and you run NN sequences in parallel, one per slot, then wait for all of them. The expected longest is m⋅HNm \cdot H_N, where HN=1+12+⋯+1N≈ln⁡N+0.58H_N = 1 + \tfrac12 + \dots + \tfrac1N \approx \ln N + 0.58 (Exercise 20.4 derives this). The slots are busy for an average of mm each out of mHNm H_N, so the utilization is

utilization=average lengthlongest length≈1HN.\text{utilization} = \frac{\text{average length}}{\text{longest length}} \approx \frac{1}{H_N}.

For N=64N = 64, H64≈4.74H_{64} \approx 4.74: the generation hardware does useful work about 21% of the time. And real length distributions have heavier tails than the exponential.

Put the pieces together: generating a token is slower than training on it, long contexts slow it further, and the tail wastes most of the rest. Systems papers on rollout efficiency report generation taking the bulk of each RL step; for example, the APRIL paper (Zhou et al. 2025) states that rollout generation accounts for more than 90% of total runtime in their setting. The exact share depends on model, lengths and hardware, but the conclusion doesn’t: in RL for LLMs, you optimize the generation stage first.

Quick check An inference engine is decoding 8 sequences of a 7B model, each with a short context. You double the batch to 16 sequences. Roughly what happens to the time per decode step?

Memory accounting: where the bytes go

Before deciding how to place things on GPUs, you need to know how big they are. The rule of thumb comes from the ZeRO paper (Rajbhandari et al. 2019).

16 bytes per trained parameter

Training with mixed-precision Adam keeps five numbers per parameter:

WhatPrecisionBytes
Weights (used in forward/backward)bf162
Gradientsbf162
Master copy of the weights (for accurate small updates)fp324
Adam first moment mmfp324
Adam second moment vvfp324
Total16

A frozen model that only runs forward passes needs just its weights: 2 bytes per parameter in bf16. These numbers exclude activations (the intermediate values saved for the backward pass, which grow with batch size and sequence length) and the KV cache. Real runs need noticeably more than this table; treat it as a floor.

PPO vs GRPO for a 7B policy

Now count the models. PPO-based RLHF (Chapter 12) has four: the policy (trained), the critic (trained, usually the same size), the reference (frozen) and the reward model (frozen). GRPO with a verifiable reward (Chapter 15) has no critic and no reward model; many 2025 recipes also drop the KL term, and with it the reference model.

Setup (7B policy)PolicyCriticReferenceReward modelTotal
PPO-RLHF, all models 7B112 GB112 GB14 GB14 GB252 GB
GRPO + KL, verifier reward112 GB14 GB126 GB
GRPO, β=0\beta = 0, verifier112 GB112 GB
GRPO + LoRA adapter (about 40M params)14 GB + 0.64 GB0≈ 15 GB

Three things to notice. First, the critic is the single biggest saving when you go from PPO to GRPO: it costs as much as the policy because it is trained too. This is the memory half of Chapter 15’s “no critic” argument. Second, none of the full fine-tuning rows fits on one 80 GB GPU even before activations and KV cache. Third, with LoRA the reference model costs nothing: the reference is the base model, and the base model is already in memory. Switch the adapter off and you have πref\pi_\text{ref}.

How do the 252 GB fit at all? By sharding: ZeRO stage 3 or PyTorch’s FSDP splits the weights, gradients and optimizer states of each model evenly across nn GPUs and gathers each layer’s weights just in time for its computation. With 8 GPUs, the PPO setup above needs about 252/8≈32252 / 8 \approx 32 GB of states per GPU, leaving the rest for activations. Very large models add tensor parallelism (splitting individual matrices across GPUs) and pipeline parallelism (splitting layers), which is what Megatron-LM provides.

Quick check You switch from PPO-RLHF to GRPO with a rule-based verifier and β = 0 (no KL term). Which models no longer need GPU memory?

Placement: colocated or disaggregated

You have a fixed set of GPUs, and two very different jobs. There are two basic ways to share the GPUs between them.

Colocated (also called a hybrid engine; DeepSpeed-Chat and verl popularized it): every GPU does both jobs, in turns. During generation the GPUs run the inference engine; then the engine releases its KV-cache memory, the same GPUs switch to training, and afterwards the new weights are resharded back into the engine’s layout. verl’s paper (Sheng et al. 2024, “HybridFlow”) calls its version the 3D-HybridEngine.

Disaggregated: some GPUs only generate, others only train. After each training step, new weights travel over the network to the generation GPUs.

How expensive is moving the weights? For a 7B model in bf16, 14 GB. Over a 400 Gb/s (50 GB/s) network link that is about

14 GB50 GB/s≈0.3 s,\frac{14\ \text{GB}}{50\ \text{GB/s}} \approx 0.3\ \text{s},

tiny next to a generation phase that takes minutes. For a 1-trillion-parameter model (2 TB in bf16) one link would need 40 seconds, so frontier systems shard the transfer across many links at once. Weight sync is usually not the bottleneck, but it is not free.

ColocatedDisaggregated
GPU idle timeNone between phases: every GPU is always doing somethingGeneration GPUs idle while the trainer works and vice versa, unless you go asynchronous
MemoryBoth jobs must fit on the same GPUs (in turns)Each pool only holds what its job needs
BalancingAutomaticYou must choose how many GPUs generate vs train
AsynchronyHard: the GPUs can’t generate and train at the same timeNatural: the two pools run concurrently
Typical fitShort or medium responses, single-turn math and code, small clustersLong reasoning, multi-turn agents with slow tools, large clusters

Production systems support both. The GLM-4.5 report, for instance, describes its slime framework running colocated and synchronous for math and code RL, and disaggregated and asynchronous for long agent rollouts. The choice comes down to the question of the next section: are you willing to train on slightly stale data?

Synchronous vs asynchronous RL

The synchronous bubble

In synchronous RL every sample is generated by the current policy, then the policy is updated, then the next batch is generated. It is exactly on-policy, the setting in which REINFORCE, PPO and GRPO were derived. It is also wasteful in two ways. Generation hardware waits for the slowest response (the long tail), and in a disaggregated setup, generation and training hardware also wait for each other.

Let generation run ahead

The fix is to let the generators keep going while the trainer works. A response started while the policy was at version vv (after vv training steps) is tagged vv. If the trainer uses it in the step that updates version v′v' to v′+1v'+1, its lag (or staleness) is v′−vv' - v. Lag 0 is on-policy. Lag 2 means the sample came from the policy of two updates ago.

To keep lags under control, systems bound how far generation may run ahead. AReaL (Fu et al. 2025) uses a simple admission rule: with batch size BB and a maximum staleness kk, a new generation may start only while

#responses started<(v+k+1) B,\#\text{responses started} \lt (v + k + 1)\, B,

where vv is the trainer’s current version. With k=0k = 0 you may start one batch, then must wait for the update: synchronous RL. With k=1k = 1 the generators may also start the next batch with the current weights while the trainer works on this one: the popular one-step off-policy design.

Here is a hand-sized example (it is also a test in the lab). Two generation lanes, batches of B=2B = 2, two training steps, each taking 1 time unit. Responses are numbered in the order they start, r0, r1, r2, …, and take 1, 3, 1, 1, 1, … time units.

  • Synchronous (k=0k = 0). r0 (length 1) and r1 (length 3) start at t=0t = 0. The batch is complete at t=3t = 3, and the trainer runs step s0 from 3 to 4. Only then may r2 and r3 start; they run from 4 to 5, and step s1 runs from 5 to 6. Total: 6 units. The lanes worked 1+3+1+1=61 + 3 + 1 + 1 = 6 of their 2×6=122 \times 6 = 12 lane-units: 50% utilization.
  • Asynchronous (k=1k = 1, so up to (0+1+1)×2=4(0 + 1 + 1) \times 2 = 4 responses may start at version 0). Lane A finishes r0 at t=1t = 1 and immediately starts r2 (tagged version 0). r2 finishes at t=2t = 2, so the trainer has two responses (r0, r2) and runs s0 from 2 to 3. Meanwhile lane A starts r3, the fourth and last response allowed at version 0. At t=3t = 3, r1 and r3 finish and the trainer, now at version 1, runs s1 on them from 3 to 4. Also at t=3t = 3, the admission limit has risen to (1+1+1)×2=6(1 + 1 + 1) \times 2 = 6, so both lanes start r4 and r5 for the next step. The lanes are never idle. Total: 4 units, 100% lane utilization, and step s1 trained on two samples with lag 1.

Drawn as a timeline (each character is a quarter of a time unit, dots are idle time):

Synchronous, k = 0
time     0   1   2   3   4   5   6
lane A   [r0]............[r2]....
lane B   [----r1----]....[r3]....
trainer  ............[s0]....[s1]

Asynchronous, k = 1
time     0   1   2   3   4
lane A   [r0][r2][r3][r4]
lane B   [----r1----][r5]
trainer  ........[s0][s1]

The demo below runs the same simulation at a larger scale.

Interactive Synchronous vs asynchronous rollouts

Block color = the policy version that generated the response (lanes) or that a training step updates (trainer). A response whose color matches the training step that uses it is on-policy. (Five colors repeat, so versions 0 and 5 look alike.)still generatingdropped as too stale

What to try: (1) With the defaults (k = 0, σ = 0.8) the lanes are busy only about a quarter of the time: watch them sit idle, waiting for the longest response and then for the trainer. (2) Set k = 1. Throughput jumps about 3×, and colors start to mix: lanes now train on responses from an older version. (3) Raise k further: speed saturates once the lanes are always busy, but the lag keeps whatever it reached. (4) Set train time to 3: now the trainer is the bottleneck, and raising k buys only staleness. (5) Tick 'drop samples older than k' and look at which blocks turn red.

The lower panel shows the real trade-off: every step of kk buys throughput and costs lag, and past the point where the slowest component is saturated, extra kk buys nothing. Two more things the demo shows that are easy to miss:

  • The admission rule bounds how far generation runs ahead, not how old every sample is. A very long response started early can finish many versions later, so the maximum lag can exceed kk (with the defaults and k=1k = 1, the demo’s max lag is 5). Systems handle these stragglers by dropping them (GLM-5 drops samples whose generating weights lag by more than a threshold), by interrupting them, or by accepting them with importance weights.
  • Dropping stale samples drops the long ones. Short responses finish fast and are trained on quickly; the samples that age past kk are the long ones. If long responses are disproportionately the hard, careful, correct ones (or the rambling, wrong ones), dropping them biases training toward one kind of behaviour. The first-in, first-out queue has the same flavour of bias: early batches over-represent short responses. Exercise 20.9 explores this.

Quick check In the demo, you set train time = 3 (the trainer is the bottleneck) and raise k from 2 to 6. What happens?

Correcting for stale samples

A sample with lag dd was drawn from πθv−d\pi_{\theta_{v-d}}, but the gradient we want is for the current πθ\pi_\theta. This is exactly the off-policy problem of Chapter 5: a behaviour policy μ\mu collected the data and we want to improve a different target policy. And Chapter 9 already gave the tool, importance sampling:

∇θJ(θ)=Ey∼πθ ⁣[A^ ∇θlog⁡πθ(y)]=Ey∼μ ⁣[πθ(y)μ(y) A^ ∇θlog⁡πθ(y)].\nabla_\theta J(\theta) = \mathbb E_{y \sim \pi_\theta}\!\big[\hat A\, \nabla_\theta \log \pi_\theta(y)\big] = \mathbb E_{y \sim \mu}\!\Big[\frac{\pi_\theta(y)}{\mu(y)}\, \hat A\, \nabla_\theta \log \pi_\theta(y)\Big].

PPO’s ratio ρt(θ)=πθ(yt∣⋅)/πold(yt∣⋅)\rho_t(\theta) = \pi_\theta(y_t \mid \cdot) / \pi_\text{old}(y_t \mid \cdot) is this correction applied per token, with πold\pi_\text{old} playing the role of μ\mu. So staleness is handled if πold\pi_\text{old} really is the policy that sampled the token. That is the practical rule: record the log-probability of every token at the moment it is sampled, and use that as the behaviour log-prob. If the trainer instead recomputes “old” log-probs with its current weights, the ratio corrects for the wrong distribution.

There is a subtlety. PPO’s clip keeps πθ\pi_\theta close to πold\pi_\text{old}, which is a trust region. When πold\pi_\text{old} is several versions behind, the clip keeps pulling the new policy back toward an outdated one. AReaL’s decoupled PPO objective separates the two roles: the behaviour policy πbehav\pi_\text{behav} (which sampled the data) gets an importance weight, and a recent proximal policy πprox\pi_\text{prox} (in practice, the weights just before the current update) anchors the clip:

Ey∼πbehav ⁣[πprox(yt∣⋅)πbehav(yt∣⋅) min⁡ ⁣(ut(θ) A^t, clip⁡(ut(θ),1−ϵ,1+ϵ)A^t)],ut(θ)=πθ(yt∣⋅)πprox(yt∣⋅).\mathbb E_{y \sim \pi_\text{behav}}\!\left[\frac{\pi_\text{prox}(y_t\mid\cdot)}{\pi_\text{behav}(y_t\mid\cdot)}\, \min\!\Big(u_t(\theta)\,\hat A_t,\ \operatorname{clip}\big(u_t(\theta), 1-\epsilon, 1+\epsilon\big)\hat A_t\Big)\right], \qquad u_t(\theta) = \frac{\pi_\theta(y_t\mid\cdot)}{\pi_\text{prox}(y_t\mid\cdot)}.

How much staleness is tolerable? Noukhovitch et al. (2024, “Asynchronous RLHF”) studied exactly this. They found online DPO the most robust of the algorithms they tried to off-policy data, with robustness growing with model size, and trained an instruction-following chatbot from Llama 3.1 8B about 40% faster than a synchronous run with matching final performance. AReaL reports up to 2.77× end-to-end speedups over synchronous systems on math and code reasoning. In practice the bounds are small: AReaL uses a maximum staleness of 4 for coding and 8 for math, and ScaleRL (below) uses a maximum off-policyness of 8.

Two more tricks for the long tail

Partial rollouts. Kimi k1.5 (Kimi Team 2025) caps the work per iteration: a response that has not finished by the end of an iteration is paused and resumed in a later one. A single response then contains segments generated by different policy versions.

In-flight weight updates. PipelineRL (Piché et al. 2025) goes further: the inference engine receives new weights in the middle of generating a sequence and continues with them, keeping its KV cache. The authors report about 2× faster learning on 128 H100s while keeping the data close to on-policy. Olmo 3 and Magistral describe the same mechanism in their RL infrastructure. As with partial rollouts, one response is now a patchwork of policy versions. The fix is the same practical rule as before, applied per token: each token’s behaviour log-prob is whatever the engine used when it sampled that token.

The train–inference mismatch

Same weights, different probabilities

Now the second surprise from the opening. Take a fully synchronous run: the trainer updates the weights, copies them exactly into the inference engine, the engine samples a batch, and the trainer trains on it. Lag zero everywhere. Surely this is on-policy?

It isn’t. The inference engine and the trainer are different programs. They compute the same mathematical function with different kernels (fused attention, different matrix-multiplication tilings), different reduction orders, sometimes different precision. Floating-point addition is not associative, so (a+b)+c(a + b) + c and a+(b+c)a + (b + c) can differ in the last bits. Run those differences through dozens of layers and a softmax, and the engine’s probability for a token, call it μθ(yt∣⋅)\mu_\theta(y_t \mid \cdot), differs from the trainer’s πθ(yt∣⋅)\pi_\theta(y_t \mid \cdot), with identical weights.

The sources of mismatch, roughly from smallest to largest:

  • Batch-dependent arithmetic. An inference server’s kernels may compute a sequence’s logits differently depending on what else is in the batch. Thinking Machines (He et al. 2025, “Defeating Nondeterminism in LLM Inference”) sampled 1,000 completions from Qwen3-235B at temperature 0 and got 80 distinct ones; with batch-invariant kernels, all 1,000 were identical.
  • Low precision. bf16 keeps only 8 significant bits. Numbers between 16 and 32 are spaced 16×2−7=0.12516 \times 2^{-7} = 0.125 apart, so a logit near 20 can be off by up to 0.0625 from rounding alone. Shifting one logit by 0.0625 multiplies that token’s (small) probability by about e0.0625≈1.065e^{0.0625} \approx 1.065: a 6.5% change. fp16 has 11 significant bits: spacing 0.01560.0156, worst case e0.0078≈1.008e^{0.0078} \approx 1.008. fp32: about 2×10−62\times10^{-6}. MiniMax-M1’s report found the LM head (the last layer, which produces the logits) was the culprit in their model, and computing it in fp32 raised the correlation between training-mode and inference-mode probabilities from about 0.9 to about 0.99.
  • MoE routing flips. In a mixture-of-experts model, a router picks the top-kk experts for each token. A tiny numerical difference can change which experts are chosen, and then the two programs compute genuinely different functions.
  • Deliberate differences. Rollouts in FP8 or FP4 to save memory and time, or top-pp sampling that truncates the distribution.

And small per-token differences compound over long responses. If each token’s log-ratio log⁡(π/μ)\log(\pi/\mu) has a random error of about 0.01, a 10,000-token response has a sequence log-ratio with standard deviation about 0.01×10,000=10.01 \times \sqrt{10{,}000} = 1. The trainer and the engine then disagree about that whole response’s probability by a factor of around e±1e^{\pm 1}, nearly 3 either way.

Why it biases the gradient

The engine samples y∼μθy \sim \mu_\theta, but the trainer computes the REINFORCE-style update as if y∼πθy \sim \pi_\theta:

gnaive=Ey∼μθ ⁣[A^∑t∇θlog⁡πθ(yt∣x,y<t)]  ≠  Ey∼πθ ⁣[A^∑t∇θlog⁡πθ(yt∣x,y<t)]=∇θJ(θ).g_\text{naive} = \mathbb E_{y \sim \mu_\theta}\!\Big[\hat A \sum_t \nabla_\theta \log \pi_\theta(y_t \mid x, y_{\lt t})\Big] \;\neq\; \mathbb E_{y \sim \pi_\theta}\!\Big[\hat A \sum_t \nabla_\theta \log \pi_\theta(y_t \mid x, y_{\lt t})\Big] = \nabla_\theta J(\theta).

The gap is a bias, and bias does not shrink with batch size. Sampling noise averages away as you add samples; a systematic error stays. Small biases accumulate step after step, and the policy can drift into regions where π\pi and μ\mu disagree even more, which makes the bias bigger. Thinking Machines’ RL experiment shows the pattern: without a correction, the reward collapsed partway through training at the same time as the measured KL between sampler and trainer spiked; with an importance-weight correction, and with bitwise-identical sampler and trainer, training was smooth.

The fix: (truncated) importance sampling

This is another off-policy correction, and the tool is again the importance weight, now between trainer and sampler. Qwen’s analysis (Zheng et al. 2025, “Stabilizing Reinforcement Learning with LLMs”) makes the bookkeeping clean. The ratio a token really needs, the current trainer policy over what actually sampled it, factors into two pieces:

πθ(yt∣⋅)μθold(yt∣⋅)=πθold(yt∣⋅)μθold(yt∣⋅)⏟train–inference mismatch×πθ(yt∣⋅)πθold(yt∣⋅)⏟policy staleness: PPO’s ρt(θ).\frac{\pi_\theta(y_t\mid\cdot)}{\mu_{\theta_\text{old}}(y_t\mid\cdot)} = \underbrace{\frac{\pi_{\theta_\text{old}}(y_t\mid\cdot)}{\mu_{\theta_\text{old}}(y_t\mid\cdot)}}_{\text{train–inference mismatch}} \times \underbrace{\frac{\pi_\theta(y_t\mid\cdot)}{\pi_{\theta_\text{old}}(y_t\mid\cdot)}}_{\text{policy staleness: PPO's } \rho_t(\theta)}.

PPO and GRPO already handle the second factor with ρt(θ)\rho_t(\theta) and its clip. The first factor is the missing piece. Call it wtw_t. It can be computed: the engine reports log⁡μθold(yt∣⋅)\log \mu_{\theta_\text{old}}(y_t \mid \cdot) as it samples, and the trainer computes log⁡πθold(yt∣⋅)\log \pi_{\theta_\text{old}}(y_t \mid \cdot) in its “prepare” pass.

Multiplying by the importance weight removes the bias (exactly with the whole-response weight, approximately with per-token weights wtw_t; the derivation below says how approximately). But importance weights have a nasty property: rare tokens can get huge weights (the sampler barely ever picks a token that the trainer likes), and huge weights mean huge variance. Truncated importance sampling (TIS), proposed for this problem by Yao et al. (2025), caps them: use min⁡(wt,C)\min(w_t, C) with a constant CC such as 2. Olmo 3’s RL objective is a clean example of the late-2025 recipe; in our notation (they write πvllm\pi_\text{vllm} for μ\mu and ρ\rho for CC):

J(θ)=1∑i∣y(i)∣∑i=1G∑tmin⁡ ⁣(wt(i), C) min⁡ ⁣(ρt(i)(θ) A^(i), clip⁡(ρt(i)(θ),1−ϵlow,1+ϵhigh)A^(i)),wt(i)=πθold(yt(i)∣⋅)μθold(yt(i)∣⋅).J(\theta) = \frac{1}{\sum_i \lvert y^{(i)}\rvert}\sum_{i=1}^{G}\sum_{t} \min\!\big(w^{(i)}_t,\, C\big)\, \min\!\Big(\rho^{(i)}_t(\theta)\,\hat A^{(i)},\ \operatorname{clip}\big(\rho^{(i)}_t(\theta), 1-\epsilon_\text{low}, 1+\epsilon_\text{high}\big)\hat A^{(i)}\Big), \qquad w^{(i)}_t = \frac{\pi_{\theta_\text{old}}(y^{(i)}_t\mid\cdot)}{\mu_{\theta_\text{old}}(y^{(i)}_t\mid\cdot)}.

Everything except min⁡(wt,C)\min(w_t, C) is the GRPO-family objective from Chapter 16 (token-level averaging, asymmetric clip, no std normalization of A^\hat A). The weight is a constant: it multiplies the gradient but no gradient flows through it.

What does truncation cost? A little bias, in exchange for bounded variance:

∂ Truncated importance sampling: exactly how much bias, and why the variance is bounded

Let w=π(y)/μ(y)w = \pi(y)/\mu(y) and let f(y)f(y) be whatever you are averaging (here, one component of A^ ∇θlog⁡πθ\hat A\,\nabla_\theta\log\pi_\theta). Assume μ(y)>0\mu(y) \gt 0 wherever π(y)>0\pi(y) \gt 0.

Untruncated IS is unbiased. Eμ[wf]=∑yμ(y)π(y)μ(y)f(y)=Eπ[f]\mathbb E_\mu[w f] = \sum_y \mu(y)\frac{\pi(y)}{\mu(y)} f(y) = \mathbb E_\pi[f].

The bias of truncation. Write min⁡(w,C)=w−(w−C)+\min(w, C) = w - (w - C)_+, where (z)+=max⁡(z,0)(z)_+ = \max(z, 0). Then

Eμ[min⁡(w,C)f]=Eμ[wf]−Eμ[(w−C)+f].\mathbb E_\mu[\min(w, C) f] = \mathbb E_\mu[w f] - \mathbb E_\mu[(w - C)_+ f].

In the second term, factor out ww: (w−C)+=w (1−C/w)+(w - C)_+ = w\,(1 - C/w)_+, so Eμ[(w−C)+f]=Eμ[w (1−C/w)+f]=Eπ[(1−C/w)+f]\mathbb E_\mu[(w-C)_+ f] = \mathbb E_\mu[w\,(1 - C/w)_+ f] = \mathbb E_\pi[(1 - C/w)_+ f]. Therefore

Eμ[min⁡(w,C)f]−Eπ[f]=− Eπ[(1−C/w)+ f].\mathbb E_\mu[\min(w, C) f] - \mathbb E_\pi[f] = -\,\mathbb E_\pi\big[(1 - C/w)_+\, f\big].

Since 0≤(1−C/w)+≤1{w>C}0 \le (1 - C/w)_+ \le \mathbb 1\{w \gt C\}, the bias is at most max⁡y∣f(y)∣⋅Pπ(w>C)\max_y \lvert f(y)\rvert \cdot P_\pi(w \gt C): it is only as large as the probability (under π\pi) of the samples you truncate. If the sampler is close to the trainer, that probability is tiny.

The variance is bounded. Because min⁡(w,C)≤C\min(w, C) \le C and min⁡(w,C)≤w\min(w,C) \le w, we have min⁡(w,C)2≤C w\min(w,C)^2 \le C\,w. So

Eμ[min⁡(w,C)2f2]≤C Eμ[wf2]=C Eπ[f2].\mathbb E_\mu\big[\min(w,C)^2 f^2\big] \le C\,\mathbb E_\mu[w f^2] = C\,\mathbb E_\pi[f^2].

The second moment is at most CC times what it would be with on-policy samples. Compare untruncated IS: Eμ[w2f2]=Eπ[wf2]\mathbb E_\mu[w^2 f^2] = \mathbb E_\pi[w f^2], which is unbounded when ww can be large.

Per-token vs per-sequence. The exact correction for a whole response is the product W=∏twtW = \prod_t w_t. As the compounding example above showed, WW can be enormous or tiny for long responses. Where do per-token weights (what Olmo 3’s objective uses) come from? Let wt(θ)=πθ(yt∣⋅)/μ(yt∣⋅)w_t(\theta) = \pi_\theta(y_t\mid\cdot)/\mu(y_t\mid\cdot) and write wt=1+δtw_t = 1 + \delta_t. The exact surrogate is Eμ[W(θ) A^]\mathbb E_\mu[W(\theta)\,\hat A]. Expand the product to first order, ∏t(1+δt)≈1+∑tδt\prod_t (1+\delta_t) \approx 1 + \sum_t \delta_t, and differentiate term by term using ∇θwt=wt∇θlog⁡πθ(yt∣⋅)\nabla_\theta w_t = w_t \nabla_\theta \log \pi_\theta(y_t\mid\cdot):

∇θ Eμ[(1+∑t(wt−1))A^]=Eμ[∑twt A^ ∇θlog⁡πθ(yt∣⋅)].\nabla_\theta\, \mathbb E_\mu\Big[\Big(1 + \sum_t (w_t - 1)\Big)\hat A\Big] = \mathbb E_\mu\Big[\sum_t w_t\, \hat A\, \nabla_\theta \log \pi_\theta(y_t\mid\cdot)\Big].

That is the per-token weighted gradient. The approximation throws away the products δsδt\delta_s \delta_t, and their gradients are still first order in δ\delta, so per-token weights are not unbiased: they fix the part of the mismatch that sits on token tt itself, but not the way mismatch on earlier tokens changed which responses were sampled. They are accurate only when every wtw_t is close to 1, and in exchange they have far lower variance than WW. The same approximation is what justifies PPO’s per-token ratio (Qwen’s analysis, Zheng et al. 2025, makes this argument). Lab Task 3 measures all of these exactly on a toy model: per-token weighting removes most of the bias, but not all of it.

Variants followed quickly. Masked importance sampling zeroes out (rather than caps) the tokens or sequences whose weight is out of range (Liu, Li et al. 2025); IcePop (Ant Group’s Ring-1T, also used by GLM-5) masks tokens whose train/inference ratio leaves a band; Kimi K2.5 and GLM-5 zero the gradient of tokens whose ratio is out of bounds, regardless of the advantage’s sign (PPO’s clip only does so for one sign). By 2026 the PPO clip was, in many production recipes, being replaced by “mask the tokens whose sampler, trainer or staleness ratio is out of band” (Chapter 16 covers the objective side of this).

Or remove the mismatch at the source

Importance weights treat the symptom. Other teams went after the cause:

  • More precision where it matters. MiniMax-M1’s fp32 LM head (above). ScaleRL found that computing the logits in fp32 raised the fitted asymptotic pass rate of its 8B runs from 0.52 to 0.61. Qi et al. (2025, “Defeating the Training-Inference Mismatch via FP16”) argue that bf16 rounding is the root cause, and that switching training and inference uniformly to fp16 removes most of the mismatch, giving more stable training across algorithms and frameworks.
  • Batch-invariant, matched kernels, so the engine and trainer produce bitwise-identical probabilities (Thinking Machines’ “true on-policy” experiment).
  • Routing replay for MoE models. Record which experts the inference engine chose for each token and force the trainer to use the same ones: DeepSeek’s “Keep Routing” (used since DeepSeek-V3-0324, per the V3.2 report) and Rollout Routing Replay (R3; Ma et al. 2025).
  • Train in the format you deploy. In 2026 several labs matched rollout and training numerics by construction, for example by running rollouts with the same low-precision expert weights (FP4) used in deployment, or quantization-aware training shared by rollout and trainer.

Quick check Your synchronous GRPO run copies weights exactly into vLLM after every step, yet you log a nonzero KL between vLLM's and the trainer's log-probs on the sampled tokens. What is the most likely explanation?

LoRA for RL

LoRA (low-rank adaptation) freezes the base model and trains a small low-rank update ΔW=BA\Delta W = BA for each weight matrix. For supervised fine-tuning it is a well-known way to save memory, at some cost in quality. For RL the trade-off looks unusually good.

Schulman and Thinking Machines (2025, “LoRA Without Regret”) report that for policy-gradient RL, “LoRA fully matches the learning performance of FullFT … even with ranks as low as 1”. Their explanation is an information count. A policy-gradient update is driven by the advantage, which carries about one number, O(1)O(1) bits, per episode, however long the episode is; supervised learning gets a target for every token. By their estimate RL absorbs about 1,000 times less information per token, so a tiny adapter has enough capacity to store what RL teaches. They also found the best LoRA learning rate is about 10× the full fine-tuning one, and that LoRA should be applied to all layers, especially the MLP (or MoE) layers. A 2026 paper pushed this to an extreme: TinyLoRA (Morris et al.) trained an 8B Qwen2.5 model to 91% on GSM8K with 13 trained parameters, and found that this works with RL but not with supervised fine-tuning, which needed far larger updates.

The systems payoff is in the memory table above: the policy’s training state shrinks from 112 GB to about 15 GB, the reference model becomes free (adapter off), and weight sync shrinks from the whole model to the adapter. Thinking Machines’ Tinker service builds on this: it exposes a LoRA training API (forward-backward, optimizer step, sampling) on hosted open-weight models, so you write the RL loop and they run the systems.

Scaling RL compute

How much compute should RL get, and does it pay off predictably? The most systematic answer so far is ScaleRL (Khatri et al. 2025, “The Art of Scaling Reinforcement Learning Compute for LLMs”). Over more than 400,000 GPU-hours of ablations, they fit the pass rate RCR_C on held-out problems after CC units of RL compute with a sigmoid,

RC−R0=(A−R0) 11+(Cmid/C)B,R_C - R_0 = (A - R_0)\,\frac{1}{1 + (C_\text{mid}/C)^B},

where AA is the asymptote (the best this recipe will ever do), BB the efficiency (how fast it gets there) and CmidC_\text{mid} the compute at which half the gain is reached. Two findings matter here. Recipes differ in their asymptote, so a recipe that looks better early can be worse late. And many popular knobs (loss aggregation, advantage normalization, curriculum, the off-policy algorithm) mostly change the efficiency BB, not the ceiling AA. Stable recipes let them fit the curve on small runs and extrapolate to a 100,000 GPU-hour run.

Their final recipe is a good snapshot of what this chapter has covered: PipelineRL-style asynchrony with at most 8 steps of off-policyness, fp32 logits, a truncated-importance-sampling loss (CISPO), and no KL term. Both the systems choices and the algorithm choices are in it, because at this scale they are not separable. Chapter 21 follows the scaling story into 2026.

The open-source stacks

You will rarely build all of this yourself. The main open-source frameworks, as of October 2026 (they change fast; check each project’s documentation):

FrameworkFromKnown for
TRLHugging FaceThe easiest start: GRPOTrainer with vLLM in colocate or server mode; current defaults reflect the 2025 consensus (no KL by default, DAPO-style token-level loss)
OpenRLHFOpen-source communityRay + vLLM + DeepSpeed; readable; PPO, REINFORCE++, async training, TIS
verl (HybridFlow)ByteDance SeedHybrid controller design and the 3D-HybridEngine for resharding between training and generation; the base of many research codebases (DAPO, SkyRL and others)
slimeZhipu / Z.ai (THUDM)Megatron + SGLang; synchronous colocated and asynchronous disaggregated modes; powers the GLM models
AReaLAnt Group (inclusionAI) + TsinghuaFully asynchronous, staleness control, decoupled PPO
PipelineRLServiceNowIn-flight weight updates
ROLLAlibabaRollout scheduling, environment and reward workers for agentic RL
prime-rlPrime IntellectAsynchronous off-policy, multi-turn and tool-first; trained Prime Intellect’s INTELLECT models
SkyRLNovaSky (Berkeley)Long-horizon agent RL (e.g. software-engineering agents)
NeMo-RLNVIDIAMegatron and DTensor backends; recipes for NVIDIA’s Nemotron models
TinkerThinking MachinesA hosted LoRA training API rather than a framework you run

A reasonable path: learn with TRL on one GPU (or with this course’s labs on a laptop), read OpenRLHF or verl to see how a full system fits together, and reach for the asynchronous frameworks when your rollouts get long or multi-turn (Chapter 18).

Summary

  • One RL step is generate → score → prepare → train → sync, and each stage runs on specialized software: an inference engine (vLLM, SGLang), a trainer (FSDP, DeepSpeed, Megatron), reward workers and a controller.
  • Generation dominates the time. Decode is memory-bandwidth-bound (each token reads all the weights), so it needs large batches; the KV cache (56 KiB per token for a 7B-class model) limits the batch and slows long contexts; and the long tail of response lengths leaves synchronous hardware idle most of the time (utilization about 1/HN1/H_N for exponential lengths).
  • Training state costs 16 bytes per parameter with mixed-precision Adam, frozen models 2. A 7B PPO-RLHF setup needs about 252 GB before activations; GRPO without a critic halves that; LoRA brings it to about 15 GB and makes the reference model free.
  • Colocated placement time-shares GPUs between generation and training; disaggregated placement gives each its own pool, which makes asynchrony natural.
  • Asynchronous RL lets generation run up to kk batches ahead, trading throughput for staleness. It is off-policy RL, corrected by importance ratios computed from log-probs recorded at sampling time. Watch for stragglers and for length bias when dropping stale samples.
  • The train–inference mismatch: with identical weights, the sampler’s probabilities μ\mu differ from the trainer’s π\pi, which biases the gradient. Fix it with truncated importance sampling min⁡(π/μ,C)\min(\pi/\mu, C) (bounded variance, small bias), or at the source with higher precision, matched kernels, routing replay and matched quantization. Always log the sampler–trainer KL.
  • With RL’s low information per episode, LoRA matches full fine-tuning in reported experiments, and RL compute scaling follows sigmoid curves whose asymptote depends on the recipe (ScaleRL).

Exercises

Exercise 20.1Where does the time go?Concept●○○

A team runs synchronous GRPO on a 7B model and profiles one step: generation 380 s, reward computation 20 s, log-prob forward passes 40 s, training 70 s, weight sync 2 s. (a) Which stage is the bottleneck? (b) Their manager proposes doubling the number of trainer GPUs. Roughly how much faster does a step get? (c) Name two changes that would help more.

Solution

(a) Generation: 380 of 512 seconds, about 74%.

(b) If training time halves (70 → 35 s) and log-prob passes also halve (40 → 20 s), the step goes from 512 s to 457 s: about 11% faster for twice the trainer hardware. This is Amdahl’s law: speeding up a part that is 21% of the time can save at most 21%.

(c) Anything that attacks generation: give generation more GPUs; increase the batch of concurrent sequences (more memory for KV cache, FP8 KV cache, prefix caching for the shared prompts); cut the long tail (a lower max length, length penalties, partial rollouts); or go asynchronous so generation never waits for training and the slowest response no longer blocks everyone. Speculative decoding (a small draft model proposes tokens that the big model verifies in parallel) is another generation-side speedup.

Exercise 20.2The memory bill of a 7B PPO runPen & paper●●○

You want to run PPO-RLHF with a 7B policy, a 7B critic, a 7B reward model and a 7B reference model, using mixed-precision Adam, on 80 GB GPUs.

(a) Compute the model-state memory of each component and the total. (b) With ZeRO-3/FSDP sharding over 8 GPUs, how much model state is on each GPU, and how much memory is left for activations and the KV cache? (c) Repeat (a) for GRPO with a verifier and a KL term, and for GRPO with a LoRA adapter of 40M parameters. (d) In the LoRA case, what is likely to become the largest consumer of memory?

Hint
Trained: 16 bytes per parameter. Frozen: 2 bytes per parameter. With LoRA, ask what the reference model is.
Solution

(a) Policy 16×7×109=11216 \times 7\times10^9 = 112 GB; critic 112 GB; reward model 2×7×109=142 \times 7\times10^9 = 14 GB; reference 14 GB. Total 252 GB.

(b) 252/8=31.5252 / 8 = 31.5 GB per GPU, leaving about 80−31.5=48.580 - 31.5 = 48.5 GB per GPU for activations, temporary buffers and (if colocated) the inference engine’s weights and KV cache. Tight but workable with gradient checkpointing.

(c) GRPO with a reference: 112+14=112 + 14 = 126 GB (no critic, no reward model). GRPO with LoRA: frozen base 1414 GB + adapter 16×4×107=0.6416 \times 4\times10^7 = 0.64 GB = 14.64 GB, and the reference model costs nothing because it is the base model with the adapter disabled.

(d) The KV cache during generation. With 15 GB of weights on an 80 GB GPU you have about 65 GB for KV cache; at 56 KiB per token (Qwen2.5-7B shape) that is about 1.1 million tokens, which long reasoning responses fill quickly (Exercise 20.3). Activations during training are the other big item.

Exercise 20.3How many responses fit?Pen & paper●●○

A GRPO step uses 64 prompts with G=8G = 8 responses each, on a model with Qwen2.5-7B’s shape (28 layers, 4 KV heads, head dimension 128, bf16 KV cache). Each prompt is 500 tokens; responses can reach 16,384 tokens.

(a) What is the KV cache per token, and for one maximum-length response (prompt included)? (b) If all 512 responses reached the maximum length at once, how much KV cache would they need? (c) On one 80 GB GPU with 15 GB of weights, how many maximum-length responses can be in flight? (d) With prefix caching, the 8 responses of a prompt share the prompt’s KV cache. How much does that save per group, as a fraction of the group’s total? When would prefix sharing matter much more?

Hint
Per token: 2×28×4×128×22 \times 28 \times 4 \times 128 \times 2 bytes. For (d), compare 7 copies of a 500-token prompt with 8 responses of 16k tokens.
Solution

(a) 2×28×4×128×2=57,3442 \times 28 \times 4 \times 128 \times 2 = 57{,}344 bytes per token. One response with its prompt: (500+16,384)×57,344≈0.97(500 + 16{,}384) \times 57{,}344 \approx 0.97 GB.

(b) 512×0.97≈496512 \times 0.97 \approx 496 GB. No single GPU comes close, which is why generation is spread over many GPUs and why the engine admits only as many sequences as fit.

(c) (80−15)/0.97≈67(80 - 15) / 0.97 \approx 67 responses at maximum length (in practice fewer, because the engine needs working memory too). This is the batch-size cap from the main text, and it is far below the roughly 300 sequences needed to make decode compute-bound.

(d) Sharing saves 7 copies of the prompt: 7×500=3,5007 \times 500 = 3{,}500 tokens out of 8×16,884=135,0728 \times 16{,}884 = 135{,}072, about 2.6%. Prefix caching matters much more when the shared prefix is long relative to the responses: multi-turn agent episodes where every turn re-reads a long conversation and tool outputs (Chapter 18), long system prompts, or long documents in the prompt.

Exercise 20.4Why long tails kill synchronous throughputPen & paper●●○

Model response lengths as independent exponential random variables with mean mm. A synchronous step runs NN responses in parallel, one per slot, and ends when the longest finishes.

(a) Show that the expected longest length is mHNm H_N, with HN=∑j=1N1/jH_N = \sum_{j=1}^N 1/j. (b) What fraction of slot-time is spent generating? Evaluate for N=8N = 8 and N=64N = 64. (c) Why does this get worse, not better, as you scale up NN? (d) Name two ways to recover the lost utilization.

Hint
For (a): the minimum of jj independent exponentials with mean mm is exponential with mean m/jm/j, and exponentials are memoryless. Think about the waiting time from one completion to the next.
Solution

(a) Wait for the first of the NN responses to finish. The minimum of NN independent exponentials with rate 1/m1/m is exponential with rate N/mN/m, so the expected wait is m/Nm/N. By memorylessness, the N−1N - 1 still running are “as good as new”, so the expected wait for the next completion is m/(N−1)m/(N-1), and so on down to m/1m/1 for the last. Adding: E[max⁡]=m(1N+1N−1+⋯+1)=mHN\mathbb E[\max] = m\left(\tfrac1N + \tfrac1{N-1} + \dots + 1\right) = m H_N.

(b) Each slot works for mm on average out of a step of expected length mHNm H_N, so the utilization is about 1/HN1/H_N (ignoring training time). H8=2.718H_8 = 2.718, utilization 37%. H64≈ln⁡64+0.577+1/128≈4.74H_{64} \approx \ln 64 + 0.577 + 1/128 \approx 4.74, utilization 21%.

(c) HNH_N grows like ln⁡N\ln N: the more responses you wait for, the more extreme the slowest one is likely to be. Scaling up the batch makes every step wait for a longer straggler.

(d) Asynchrony (let free slots start the next batch: the demo’s k≥1k \ge 1); partial rollouts or in-flight weight updates; more responses than slots per step, so free slots pick up new work (continuous batching within a step); a lower maximum length or length penalties; rebalancing (spreading long responses over GPUs, speculative decoding for the tail).

Exercise 20.5Importance weights for stale samplesPen & paper●●○

A response yy for prompt xx was sampled by the inference engine running weights θv−d\theta_{v-d} (lag dd), and the engine recorded log⁡μ(yt∣⋅)\log\mu(y_t\mid\cdot) for every token. The trainer is about to update from θv\theta_v using PPO’s clipped objective.

(a) Write the sequence-level importance-sampling identity that turns an expectation under πθ\pi_\theta into one under μ\mu. (b) Show that the per-token ratio the trainer needs factors as (mismatch) × (staleness) × (PPO ratio), with three factors involving μ\mu, πθv−d\pi_{\theta_{v-d}}, πθv\pi_{\theta_v} and πθ\pi_\theta. (c) A colleague computes PPO’s “old” log-probs by running the trainer’s current weights θv\theta_v on the batch and ignores the engine’s recorded log-probs. Which factors does their ratio leave out? (d) Which factors are close to 1 when d=0d = 0 and sampler and trainer are bitwise identical?

Hint
Multiply and divide by πθv−d(yt∣⋅)\pi_{\theta_{v-d}}(y_t\mid\cdot) and by πθv(yt∣⋅)\pi_{\theta_v}(y_t\mid\cdot).
Solution

(a) For any function ff, Ey∼πθ[f(y)]=∑yμ(y) πθ(y∣x)μ(y∣x)f(y)=Ey∼μ[πθ(y∣x)μ(y∣x)f(y)]\mathbb E_{y\sim\pi_\theta}[f(y)] = \sum_y \mu(y)\,\frac{\pi_\theta(y\mid x)}{\mu(y\mid x)} f(y) = \mathbb E_{y\sim\mu}\big[\frac{\pi_\theta(y\mid x)}{\mu(y\mid x)} f(y)\big], valid where μ(y∣x)>0\mu(y\mid x) \gt 0 whenever πθ(y∣x)>0\pi_\theta(y\mid x) \gt 0.

(b) Per token,

πθ(yt∣⋅)μ(yt∣⋅)=πθv−d(yt∣⋅)μ(yt∣⋅)⏟mismatch⋅πθv(yt∣⋅)πθv−d(yt∣⋅)⏟staleness⋅πθ(yt∣⋅)πθv(yt∣⋅)⏟PPO ratio ρt(θ).\frac{\pi_\theta(y_t\mid\cdot)}{\mu(y_t\mid\cdot)} = \underbrace{\frac{\pi_{\theta_{v-d}}(y_t\mid\cdot)}{\mu(y_t\mid\cdot)}}_{\text{mismatch}} \cdot \underbrace{\frac{\pi_{\theta_v}(y_t\mid\cdot)}{\pi_{\theta_{v-d}}(y_t\mid\cdot)}}_{\text{staleness}} \cdot \underbrace{\frac{\pi_\theta(y_t\mid\cdot)}{\pi_{\theta_v}(y_t\mid\cdot)}}_{\text{PPO ratio } \rho_t(\theta)}.

The middle factors cancel pairwise, so the identity holds exactly. Here μ\mu is the engine’s version of πθv−d\pi_{\theta_{v-d}}.

(c) Their ratio is only πθ/πθv\pi_\theta/\pi_{\theta_v}, the PPO ratio. It leaves out both the mismatch factor and the staleness factor, so with lag d>0d \gt 0 the update is biased toward whatever θv−d\theta_{v-d} liked to sample. Recording the engine’s log-probs and using them (with truncation for the mismatch part) fixes both. In AReaL’s decoupled objective the trust-region center is πθv\pi_{\theta_v} (the proximal policy) and the importance weight is πθv/μ\pi_{\theta_v}/\mu, which is exactly the product of the first two factors.

(d) With d=0d = 0 the staleness factor is exactly 1. With bitwise-identical sampler and trainer, the mismatch factor is exactly 1. Only PPO’s ratio is left, which is 1 at the first minibatch step and moves away from 1 as the trainer takes more steps on the batch.

Exercise 20.6Truncated IS: bias and variance by handPen & paper●●●

A two-token vocabulary. The trainer’s policy is π=(0.5,0.5)\pi = (0.5, 0.5), the sampler’s is μ=(0.8,0.2)\mu = (0.8, 0.2), and you want Eπ[f]\mathbb E_\pi[f] for f=(0,1)f = (0, 1) (so the true value is 0.5).

(a) Compute the expectation under μ\mu of the naive estimator f(y)f(y), of the IS estimator w(y)f(y)w(y) f(y), and of the truncated estimator min⁡(w(y),C)f(y)\min(w(y), C) f(y) with C=2C = 2. (b) Compute the variance of the IS and the truncated estimators. (c) Check your bias against the formula −Eπ[(1−C/w)+f]-\mathbb E_\pi[(1 - C/w)_+ f] from the derivation, and your truncated second moment against the bound C Eπ[f2]C\,\mathbb E_\pi[f^2].

Hint
The weights are w=π/μ=(0.625,2.5)w = \pi/\mu = (0.625, 2.5).
Solution

(a) Weights: w(0)=0.5/0.8=0.625w(0) = 0.5/0.8 = 0.625, w(1)=0.5/0.2=2.5w(1) = 0.5/0.2 = 2.5. Since f(0)=0f(0) = 0, only token 1 matters.

  • Naive: Eμ[f]=0.2×1=0.2\mathbb E_\mu[f] = 0.2 \times 1 = 0.2. Bias −0.3-0.3.
  • IS: Eμ[wf]=0.2×2.5=0.5\mathbb E_\mu[w f] = 0.2 \times 2.5 = 0.5. Unbiased.
  • Truncated, C=2C = 2: Eμ[min⁡(w,2)f]=0.2×2=0.4\mathbb E_\mu[\min(w,2) f] = 0.2 \times 2 = 0.4. Bias −0.1-0.1.

(b) IS: second moment 0.2×2.52=1.250.2 \times 2.5^2 = 1.25, variance 1.25−0.52=1.01.25 - 0.5^2 = 1.0. Truncated: second moment 0.2×22=0.80.2 \times 2^2 = 0.8, variance 0.8−0.42=0.640.8 - 0.4^2 = 0.64. (Naive: 0.2−0.04=0.160.2 - 0.04 = 0.16, low variance but badly biased.)

(c) Bias formula: (1−C/w)+(1 - C/w)_+ is 00 for token 0 (since w<Cw \lt C) and 1−2/2.5=0.21 - 2/2.5 = 0.2 for token 1, so −Eπ[(1−C/w)+f]=−0.5×0.2×1=−0.1-\mathbb E_\pi[(1 - C/w)_+ f] = -0.5 \times 0.2 \times 1 = -0.1. ✓. Bound: C Eπ[f2]=2×0.5=1.0≥0.8C\,\mathbb E_\pi[f^2] = 2 \times 0.5 = 1.0 \ge 0.8 ✓.

With a batch of NN samples the mean squared error is bias² + variance/NN. For N=16N = 16: naive 0.09+0.01=0.1000.09 + 0.01 = 0.100; IS 0+0.0625=0.06250 + 0.0625 = 0.0625; truncated 0.01+0.04=0.0500.01 + 0.04 = 0.050. Truncation wins here; for very large NN the unbiased IS estimator eventually wins, because bias never averages away.

Exercise 20.7When is off-policy-ness harmless?Concept●●○

For each situation, say whether training on the stale or mismatched data is likely to be harmless, harmful, or fixable, and why. (a) One-step off-policy GRPO with a small learning rate and recorded sampler log-probs. (b) Lag up to 8 with a large learning rate and no importance correction. (c) A 1-trillion-parameter MoE model whose sampler and trainer pick different experts for 5% of tokens. (d) Asynchronous online DPO with moderate staleness. (e) Dropping every sample with lag above 1 in a run where hard problems produce long responses.

Solution

(a) Mostly harmless. With a small learning rate, consecutive policies are close, so the importance ratios are near 1; the recorded log-probs let the PPO ratio and clip correct what difference remains. This is why one-step off-policy is a popular default.

(b) Harmful. Eight large steps can move the policy far from the one that sampled the data. Without a correction, the gradient is computed as if the data came from the current policy, and it is biased toward the old policy’s behaviour. Fix: record behaviour log-probs, use importance ratios with clipping or truncation (or a decoupled objective), lower the learning rate, or reduce kk.

(c) Harmful unless addressed. A routing flip means the two programs compute different functions for that token, so the ratio can be far from 1 and noisy. Truncation limits the damage, but the robust fix is routing replay (use the sampler’s expert choices in training), as DeepSeek’s Keep Routing and R3 do.

(d) Fairly harmless, per Noukhovitch et al. (2024), who found online DPO the most robust to off-policy data among the algorithms they tested, increasingly so at larger model sizes. DPO’s loss is defined on pairs and does not assume the samples came from the current policy (it is an offline method at heart; Chapter 13).

(e) Harmful in a quiet way. The dropped samples are the stragglers: the long responses, which here are the hard problems. The model then trains mostly on easy problems and short answers, biasing it toward brevity and away from difficult reasoning. Fixes: a larger kk with importance correction instead of dropping, partial rollouts or in-flight updates so long responses aren’t systematically late, or monitoring the length distribution of trained vs generated samples.

Exercise 20.8Colocated or disaggregated?Concept●●○

Choose a placement (colocated or disaggregated, synchronous or asynchronous) for each scenario and justify it in two or three sentences. (a) GRPO on a 1.5B model for grade-school math, 8 GPUs, responses under 2,000 tokens, an instant verifier. (b) A 32B coding agent on 256 GPUs whose episodes include running test suites in sandboxes that take 30 seconds to 10 minutes. (c) PPO-RLHF of a 70B policy with a 70B reward model on 64 GPUs.

Hint
Ask in each case: is generation the bottleneck? Is the tail long? What dominates memory? Can any hardware sit idle?
Solution

(a) Colocated, synchronous (perhaps one-step off-policy). The model is small, responses are short and fairly uniform, and rewards are instant, so the tail bubble is modest. Colocation keeps all 8 GPUs busy in both phases, weight sync is a cheap local reshard, and synchronous training removes a whole class of off-policy issues. Splitting 8 GPUs into two pools would leave each pool idle half the time.

(b) Disaggregated, asynchronous, with a generous staleness bound and importance correction. Episodes are dominated by tool and sandbox latency, during which the policy GPUs have nothing to do; lengths are extremely variable. The generator pool should keep many episodes in flight (most of them waiting on sandboxes) while the trainer consumes completed ones. Synchronous training would idle the cluster for the slowest 10-minute episode every step. Track the lag and the length distribution of what gets trained on.

(c) Memory decides here. Policy and critic in training need 2×16×70×109=2.242 \times 16 \times 70\times10^9 = 2.24 TB, and the reference and reward model 280 GB more: about 2.5 TB of state, roughly 39 GB per GPU when sharded over 64 GPUs. A colocated hybrid engine with time-sharing (load and offload models per phase) makes the best use of that memory, which is how HybridFlow/verl and OpenRLHF were designed. An alternative is a separate pool that serves the reward model, so the training GPUs never hold it. Responses in classic RLHF are short, so the long tail is less severe than in reasoning RL.

Exercise 20.9Explore the demo: speed, staleness and who gets droppedExplore the demo●○○

Use the demo above. (a) With the defaults (16 lanes, σ = 0.8, train time 1), read the lane utilization at k = 0, then the speedup and mean lag at k = 1 and k = 2. Why doesn’t k = 3 help further? (b) Set σ = 0 (all responses the same length). Is asynchrony still useful? Why? (c) Return to σ = 0.8, set train time = 3, and move k from 1 to 6. What happens to speed and lag? (d) Set train time back to 1 and k = 1, and tick “drop samples older than k”. What fraction of generation is wasted, and which responses (short or long) turn red? Why could this matter for training a reasoning model?

Solution

Numbers below are for the default seed; “New lengths” changes them a little.

(a) At k = 0 the lanes are busy only about 26% of the time (and the trainer about 19%). k = 1 gives about 3.1× the throughput, with a mean lag of about 1 and occasional stragglers up to 5. k = 2 gives about 3.9× with mean lag about 1.7, and the lanes are now busy 100% of the time. Once generation never waits, generation is the bottleneck, and nothing about the trainer’s scheduling can make it faster; k = 3 changes nothing.

(b) Yes: about 2×. Even with identical lengths, synchronous disaggregated RL alternates “generate, then train”, so each pool idles while the other works. Asynchrony overlaps them. With σ = 0 the mean lag is about 1 (one-step off-policy).

(c) Speed rises from k = 0 to 1 and then flattens at about 2.4×, because the trainer (busy about 99% of the time) is now the bottleneck. But the mean lag keeps rising, to more than 5 at k = 6: samples pile up in the trainer’s queue and wait longer. Extra staleness, no extra speed.

(d) About a third of generation time is wasted (35% with the default seed), the maximum lag drops to 1, and throughput drops from about 3.1× to about 2.8×. The red blocks are the long responses: short ones finish in time, long ones fall behind and get dropped. For a reasoning model, long responses are often the hard problems or the careful solutions, so dropping them biases training toward short, easy successes, and can push response length down for reasons that have nothing to do with the reward.

Exercise 20.10Lab: a memory calculator, a rollout scheduler, and TISCode●●○

Do the three tasks in the lab below.

  • Task 1 (memory.py): model_state_bytes, kv_cache_bytes and rl_memory_gb. The tests check the 252 GB / 126 GB / 14.64 GB numbers from the memory table.
  • Task 2 (scheduler.py): may_start (AReaL’s admission rule) and take_batch (first-in-first-out batching with lag bookkeeping and optional dropping) inside a given discrete-event simulator. Two tests reproduce the hand example from the “Let generation run ahead” section: 6 time units synchronous, 4 asynchronous.
  • Task 3 (tis.py): importance_weights for five schemes (none, per-token IS, per-token TIS, sequence IS, sequence TIS) and pg_estimate. The tests compute exact bias and variance on a toy model with 4,096 possible responses.

Then run python run.py and compare the plots with the README’s “What you should see”.

Hint
For Task 3a, compute log_w = logp_train - logp_sampler once; the sequence weight is np.exp(log_w.sum(axis=1, keepdims=True)). For Task 2b, filter the queue first, then check whether at least B samples remain.
Solution

The reference solutions are in solutions/. The key lines:

  • model_state_bytes: return 16 * n_params if trainable else bytes_per_weight * n_params. kv_cache_bytes: return 2 * n_layers * n_kv_heads * head_dim * n_tokens * bytes_per_value. rl_memory_gb assembles the components; the only subtle line is that with LoRA the reference costs 0.
  • may_start: return n_started - n_dropped < (version + max_staleness + 1) * batch_size. With max_staleness = 0 this reproduces synchronous RL, and the hand test checks it.
  • take_batch: if drop_stale, split the queue into samples with version - s["tag"] > max_staleness (dropped) and the rest; if fewer than B remain return (None, dropped, remaining); otherwise set s["lag"] = version - s["tag"] on the first B and return them.
  • importance_weights: log_w = logp_train - logp_sampler; per-token modes exponentiate it (and np.minimum(..., C) for TIS); sequence modes exponentiate the row sum and broadcast it to every token.
  • pg_estimate: return (rewards - baseline)[:, None, None] * weights[:, :, None] * score.

What run.py shows: the top row reproduces the demo’s trade-off (a 2–5× speedup that saturates at small k, paid for with a mean lag of 1–2); the bottom row shows the naive estimator’s bias growing linearly with the mismatch, sequence IS staying unbiased but exploding in variance, and the truncated estimators giving the lowest total error.

Files: README.md, memory.py, scheduler.py, tis.py, scheduler_core.py, toy.py, test_rl_systems.py, run.py

cd 20-rl-systems
python -m pytest -q          # your code: tests fail until you fill in the TODOs
RL_SOLUTIONS=1 python -m pytest -q   # reference solutions: all pass

First time? See Exercises & setup.

Further reading

Next, Chapter 21 steps back to survey what the latest models actually do in post-training, and the open problems worth watching.