14.1 Padding and its waste
collate pads every episode of a batch to the longest one. The cost is visible from the lengths alone: a padded batch computes positions, of which are real.
pack_sequences places trajectories into rows of max_tokens with first-fit decreasing: longest first, each into the first row with room. It returns the packed ids, per-token position ids and segment ids (the index of the sequence within its row, for padding), and the action masks and sampler log-probabilities in the same [R, L-1] layout as collate. A sequence’s last position predicts nothing of its own, so the mask entry that would predict the next sequence’s first token stays 0.
forward builds its own causal mask from the sequence length, so a packed forward pass has to be assembled from its parts: token embeddings plus position embeddings at the given position ids, the transformer blocks with an explicit mask, the final norm and head. packed_logits builds the mask from the segment ids: position may attend to position if and both belong to the same segment. PyTorch’s attention takes a per-row mask as a float tensor of shape [R * n_heads, L, L], with where attention is forbidden. Padding positions attend to themselves only, so no row of the softmax is empty.
14.2 Token budgets and length-balanced microbatches
Packing fixes waste within a row; the next question is how many sequences go into a microbatch. A fixed number of sequences per microbatch makes memory proportional to the longest sequence that happens to land in it. Agent trajectories have long tails, so a fixed count either wastes memory on most microbatches or runs out on the unlucky one. A token budget fixes the quantity memory depends on instead: each microbatch holds as many sequences as fit inmax_tokens padded positions. Sorting by length first (length-balanced microbatches) keeps sequences of similar length together, which cuts padding further.
14.3 The memory of logits
Section 3.6 introducedchunked_log_probs and noted that chunking alone saves forward memory but not backward memory. Here is the arithmetic for a training microbatch. For tokens and vocabulary , the full logits tensor holds numbers. Qwen3’s is 151,936, so one token’s logits are 0.6 MB in fp32, and a 32k-token microbatch has 20 GB of logits before the log-softmax output and the gradient, each of the same size. The model’s own per-token activations at hidden width are a few times per layer, and is 37 to 148 times larger than for Qwen3’s sizes.
torch.utils.checkpoint does the second part: it runs the chunk without saving intermediates and replays it during backward. checkpointed_log_probs is Chapter 3’s chunked_log_probs with that one change.
14.4 Gradient accumulation and loss normalization
A batch that does not fit in memory is split into microbatches whose gradients are summed before the optimizer step. The sum must equal the gradient of the full batch’s loss, and withtoken_mean it usually does not. token_mean divides by the number of policy tokens in the batch. A microbatch loop that computes token_mean per microbatch and divides by the number of microbatches gives each microbatch equal total weight, so a token’s weight becomes instead of , where is its microbatch’s token count, the number of microbatches, and . Tokens in short microbatches are up-weighted. The fix is to divide every microbatch’s token sum by the global , which aggregate_loss already supports as mode='constant' with norm_const=N. The same bug appears across data-parallel ranks when each rank normalizes by its own token count before the gradient all-reduce.
token_mean, a random split already has a relative error of 8% (cosine 0.997), because the microbatches hold 159 to 208 policy tokens. A length-sorted split, which is what a token-budget scheduler produces, holds 96 to 355 tokens per microbatch, and the error rises to 52% (cosine 0.88): the gradient points in a measurably different direction. Since the short microbatch consists of short episodes, which in this gym are mostly immediate submits, the bug systematically over-weights one kind of behavior. The loss value printed during training gives no hint of this.
14.5 Filtering tasks by pass rate
The default run of Chapter 9 spent most of its late rollouts on groups with zero advantage: by steps 60 to 80, about 80% of sampled groups passed in every attempt or failed in every attempt. Those episodes cost a full rollout each and contribute no gradient. The cheapest fix is upstream of the loss: do not sample tasks whose outcome is already certain. With a running estimate of each task’s pass rate, a pass-rate filter keeps task for sampling only if . With and , it drops exactly the tasks whose last group was all-fail or all-pass. A softer variant samples tasks with probability proportional to , the variance of a Bernoulli reward, which is the expected size of a group’s advantage signal.train_curriculum is train_rl with a task pool that changes. Each task’s estimate is its last group’s mean reward, initialized at 0.5 so that every task is sampled at first; the estimates cost no extra rollouts. Each step samples prompts_per_step tasks from those inside the filter, plus revisit tasks drawn at random from those outside it, so that an excluded task’s estimate is refreshed now and then and a task the policy forgets can come back. When few tasks pass the filter, the step samples fewer episodes: that is the point. A large task set would keep the batch full with informative tasks; our 20 training tasks show the saving directly as rollouts not spent.
low=-1 and high=2, every task passes the filter, the pool is the whole training set, and the loop samples 8 tasks per step exactly as train_rl does, so the unfiltered arm runs through the same code. We train 60 steps per run, two seeds per arm, and evaluate on held-out tasks with 16 episodes per task.
Evidence. Measured here, two seeds per arm, 60 steps on 20 training tasks. The rollout saving is a direct consequence of the filter and is robust; the comparison of held-out pass rates is inside seed noise. Figure 14.3 adds two more seeds.
14.6 Throughput accounting
Every design decision in this chapter and the last trades one resource for another, and the trade can only be judged with a budget. The budget has two halves that behave differently: generation and training. Generation is a sequence of decode steps. Each step produces one token per sequence and must read every weight and every sequence’s KV cache from memory, so its speed is set by memory bandwidth, not FLOPs. TinyAgent shows the other half of the story first.rollout_batch has no KV cache: every decode step reruns the model over the whole prefix. We count the positions it processes for the 64 episodes of Section 14.1 and compare with a KV-cached decoder, which processes each position once.
Evidence. The H200 bandwidth and throughput figures are NVIDIA’s published specifications (inspected 2026-10-13); the KV size is computed from Qwen3-8B’s configuration file. Everything else in this section (episode shapes, batch sizes, MFU, busy fraction) is an assumption chosen for illustration, and the result is a first-order estimate that ignores attention FLOPs, communication, evaluation, and failed runs. Replace each assumption with a measurement from a pilot run before planning a budget.
14.7 Design problems in practice
Problem: loss normalization under gradient accumulation. Symptom: changing the microbatch size, the number of data-parallel ranks, or the batch scheduler changes the learning curve, although the global batch is the same; short episodes seem to learn faster after a switch to length-sorted microbatches. Cause: per-microbatchtoken_meangives each microbatch equal weight, so a token’s weight is times what the global loss assigns (Section 14.4). Length-sorted microbatches make vary the most. Options: (a) normalize by the global token count (an all-reduce of the count before the backward pass in data parallel). (b) Use a constant normalizer (Dr. GRPO’sconstantmode), which needs no global count and is independent of batch composition. (c) Equal-token microbatches, which only make the error small. Default: (a) or (b), with a unit test that compares an accumulated gradient against the single-pass gradient, as Section 14.4 does. The block prints the per-token weight distortion of the length-sorted split.
Problem: the packing attention leak. Symptom: packed training runs without errors, but the step-0 ratio between the trainer’s log-probabilities and the sampler’s is far from 1, the clip fraction is high from the first minibatch, and the policy learns slowly or diverges. Cause: a causal mask over a packed row lets each sequence attend to the sequences packed before it, and running position ids shift every later sequence; the trainer then scores tokens under contexts the sampler never saw (Section 14.1). Options: (a) a block-diagonal mask or variable-length attention kernel with restarted position ids. (b) No packing, paying the padding. Default: (a), with a test that compares packed and unpacked log-probabilities on real trajectories. The sampler’s own log-probabilities make the bug visible without any reference run.
Problem: wasted rollouts on zero-variance prompts. Symptom: the zero-advantage share of groups climbs as training succeeds; rollouts per useful gradient rise; the learning curve flattens long before the held-out pass rate stops improving. Cause: a group whose rewards are all equal has zero advantage under every baseline, so each of its episodes is a full rollout with no gradient (Chapter 9). Options: (a) pass-rate filtering before sampling (Section 14.5), which needs per-task estimates and can starve tasks whose estimate is stale. (b) Dynamic sampling, which keeps the batch full by sampling more. (c) Larger groups for hard tasks, so a rare success is sampled. Default: (a) with occasional revisits of excluded tasks, plus logging the zero-advantage share. The block counts the episodes that landed in zero-advantage groups in the runs of Section 14.5.
Problem: one long-tail sequence dominates memory. Symptom: out-of-memory errors at random steps, long after the run started, always on the rank that received the longest trajectory; lowering the batch size makes them rarer but not impossible. Cause: with a fixed number of sequences per microbatch, padded memory is the count times the longest member, and with a long-tailed length distribution the longest member eventually reaches the context limit. A packed or padded microbatch of 8 sequences then costs 8 times the context length. Options: (a) a token budget (Section 14.2), which puts the longest trajectories in microbatches of their own. (b) Chunked and recomputed log-probs (Section 14.3), which shrink the per-token cost but not its growth. (c) A hard length cap with truncation, which changes the task. Default: (a) and (b) together, with the budget set from a measured peak at the maximum context length. The block finds the worst microbatch under a fixed count of 8 and the microbatch the token budget gives the same trajectory.
Summary
- Padding TinyAgent’s 64 episodes to the longest wasted 74% of the computed positions. Packing filled rows to 97%, and it is correct only with a block-diagonal causal mask and position ids that restart per sequence: without them, policy log-probabilities moved by up to 6.4 nats, with them they matched to .
- A fixed number of sequences per microbatch makes memory proportional to the longest sequence; for a long-tailed length distribution, a token budget with length sorting cut the largest microbatch from 524k to 131k tokens at 3% padding.
- Logits are the largest memory item of long-context RL at production vocabularies. Measured on Qwen3-0.6B’s output layer: 29.9 GB at 16k tokens in full, 11.2 GB chunked, 2.2 GB chunked with recomputation, independent of length.
- Gradient accumulation with per-microbatch
token_meanchanges the gradient: 8% relative error for a random split and 52% for a length-sorted one on TinyAgent. Normalizing every microbatch by the global token count (mode='constant') reproduced the full-batch gradient to . - Filtering tasks by their last group’s pass rate cut sampled episodes by 43% to 46% over 60 steps at a held-out pass rate within seed noise. Throughput arithmetic for an 8B agent run puts decoding at about 60% of the GPU-hours, because long-context decode is memory-bound at roughly a quarter of the training token rate.
Exercises
- Write a unit test for
pack_sequencesandpacked_logitsthat fails for each of the two bugs of Section 14.1 separately. What is the smallest pair of sequences that exposes the position bug but not the mask bug? - Extend
token_budget_batchesto packed microbatches, where the cost is the sum of lengths rather than the count times the longest. How many microbatches does the 512-trajectory example need at a 128k budget? - Derive the per-token weight under per-microbatch
token_meanfor data-parallel ranks, each with its own microbatches. Show that normalizing by the global token count requires one all-reduce of a scalar before the backward pass, and implement it withaggregate_loss. - Change an assumption: rerun Section 14.5 with the filter based on the mean of each task’s last three groups instead of its last group. Does the held-out gap close? How many more episodes does it cost?
- Implement sampling proportional to instead of a hard filter, and compare the episodes spent and the held-out pass rate with the hard filter.
- Redo the GPU-hour estimate of Section 14.6 for a 64k-token context. Which term grows, and by how much? What would a 4x smaller KV cache (for example, from more aggressive grouped-query attention or an 8-bit KV cache) buy?
- Design review: a teammate proposes to raise the microbatch size from 4 to 16 sequences to improve training MFU. The run uses 32k-token agent trajectories with a long tail and per-microbatch loss normalization. List the three things that change besides MFU, with the measurement you would ask for on each.