14.1 填充及其浪费
collate 把一个批次中的每个回合(episode)都填充到最长回合的长度。仅从长度就能看出代价:一个填充后的批次要计算 个位置,其中真实的只有 个。
pack_sequences 用首次适应递减(first-fit decreasing)把轨迹放入长度为 max_tokens 的行:最长的先放,每条放进第一个有空间的行。它返回打包后的 id、逐 token 的位置 id 和段 id(序列在所在行中的索引,填充为 ),以及与 collate 相同 [R, L-1] 布局的动作掩码(action mask)和采样器对数概率。一条序列的最后一个位置不为自己预测任何东西,因此本会预测下一条序列第一个 token 的那个掩码项保持为 0。
forward 根据序列长度自行构建因果掩码,所以打包后的前向传播必须由各个部件拼装:token 嵌入加上给定位置 id 处的位置嵌入,带显式掩码的 transformer 块,最终的归一化层和输出头。packed_logits 根据段 id 构建掩码:如果 且两者属于同一段,位置 就可以关注位置 。PyTorch 的注意力接受形状为 [R * n_heads, L, L] 的浮点张量作为逐行掩码,禁止关注的位置为 。填充位置只关注自身,所以 softmax 的每一行都不为空。
14.2 Token 预算与长度均衡的微批次
打包解决了行内的浪费;下一个问题是一个微批次里放多少条序列。每个微批次固定序列条数,会让内存与恰好落入其中的最长序列成正比。智能体轨迹有长尾,所以固定条数要么在大多数微批次上浪费内存,要么在运气不好的那个上内存溢出。token 预算固定的则是内存真正依赖的量:每个微批次容纳尽可能多的序列,只要总填充位置不超过max_tokens。先按长度排序(长度均衡的微批次)可以让长度相近的序列放在一起,进一步减少填充。
14.3 logits 的内存
第 3.6 节介绍了chunked_log_probs,并指出仅靠分块只能节省前向内存,不能节省反向内存。下面是一个训练微批次的算术。对 个 token 和词表(vocabulary)大小 ,完整的 logits 张量有 个数。Qwen3 的 是 151,936,所以一个 token 的 logits 在 fp32 下是 0.6 MB,一个 32k token 的微批次在 log-softmax 输出和梯度之前就有 20 GB 的 logits,而这两者各自也同样大。在隐藏宽度 下,模型自身的逐 token 激活每层只是 的几倍,而对 Qwen3 的各个规模, 是 的 37 到 148 倍。
torch.utils.checkpoint 负责第二部分:它运行分块时不保存中间结果,并在反向时重放。checkpointed_log_probs 就是第 3 章的 chunked_log_probs 加上这一处改动。
14.4 梯度累积与损失归一化
内存放不下的批次被拆分成多个微批次,在优化器步之前把它们的梯度加起来。累加结果应等于对完整批次求梯度。但若每个微批次各自使用token_mean,通常就不相等。token_mean 除以批次中策略 token 的数量。如果微批次循环对每个微批次计算 token_mean,再除以微批次数量,那么每个微批次得到相等的总权重,于是一个 token 的权重变成 而不是 ,其中 是其所在微批次的 token 数, 是微批次数量,。短微批次中的 token 被加权过高。修复方法是把每个微批次的 token 损失之和都除以全局 ,aggregate_loss 已经通过 mode='constant' 加 norm_const=N 支持这一点。当每个数据并行 rank 在梯度 all-reduce 之前用自己的 token 数做归一化时,跨 rank 也会出现同样的 bug。
token_mean 时,随机拆分就已经有 8% 的相对误差(余弦相似度 0.997),因为各微批次含有 159 到 208 个策略 token。按长度排序的拆分(token 预算调度器产生的正是这种拆分)每个微批次含 96 到 355 个 token,误差升到 52%(余弦相似度 0.88):梯度指向一个可测量的不同方向。由于短微批次由短回合组成,而在这个 gym 中短回合多数是立即提交,这个 bug 会系统性地过度加权某一种行为。训练期间打印的损失值对此毫无提示。
14.5 按通过率过滤任务
第 9 章的默认运行把后期的大部分 rollout 花在了优势(advantage)为零的组上:到第 60 到 80 步,约 80% 的采样组要么每次尝试都通过,要么每次都失败。这些回合每个都要付出一次完整 rollout 的代价,却不贡献任何梯度。最便宜的修复位于损失的上游:不要采样结果已经确定的任务。有了对每个任务通过率的滚动估计 ,通过率过滤只在 时保留任务 用于采样。当 、 时,它恰好丢弃上一组全部失败或全部通过的任务。一个更柔和的变体按与 成正比的概率采样任务,这是伯努利奖励的方差,也就是一个组的优势信号的期望大小。train_curriculum 就是任务池会变化的 train_rl。每个任务的估计值是它上一组的平均奖励,初始化为 0.5,使每个任务一开始都会被采样;这些估计不需要额外的 rollout。每一步从过滤范围内的任务中采样 prompts_per_step 个任务,再从范围外的任务中随机抽取 revisit 个,这样被排除任务的估计会不时得到更新,策略遗忘的任务也能回来。当通过过滤的任务很少时,这一步采样的回合就更少:这正是目的所在。大型任务集可以用有信息量的任务把批次填满;我们的 20 个训练任务则把节省直接体现为没有花掉的 rollout。
low=-1、high=2 时,每个任务都能通过过滤,任务池就是整个训练集(train split),循环每步采样 8 个任务,与 train_rl 完全相同,因此不过滤的一组跑的是同一份代码。每次运行训练 60 步,每组两个随机种子(seed),并在留出(held-out)任务上评估,每个任务 16 个回合。
**证据。**本书测得,每组两个随机种子,在 20 个训练任务上训练 60 步。rollout 的节省是过滤的直接结果,是稳健的;留出通过率的比较则在种子噪声范围之内。图 14.3 又增加了两个种子。
14.6 吞吐量核算
本章和上一章中的每个设计决策都是在用一种资源换另一种资源,而只有在预算下才能评判这种交换。预算有两半,行为各不相同:生成和训练。 生成是一系列解码步。每一步为每条序列产生一个 token,并且必须从内存中读取全部权重和每条序列的 KV cache,因此它的速度由内存带宽而不是 FLOPs 决定。TinyAgent 先展示了问题的另一半。rollout_batch 没有 KV cache:每个解码步都在整个前缀上重新运行模型。我们统计它在第 14.1 节的 64 个回合上处理的位置数,并与带 KV cache 的解码器比较,后者每个位置只处理一次。
**证据。**H200 的带宽和吞吐量数字是 NVIDIA 公布的规格(查阅于 2026-10-13);KV 大小由 Qwen3-8B 的配置文件计算得出。本节其余内容(回合形状、批大小、MFU、忙碌比例)都是为说明而选的假设,结果是一个一阶估计,忽略了注意力 FLOPs、通信、评估和失败的运行。在规划预算之前,请用试点运行的测量值替换每一项假设。
14.7 实践中的设计问题
问题:梯度累积下的损失归一化。 现象: 改变微批次大小、数据并行 rank 数或批次调度器会改变学习曲线,尽管全局批次相同;切换到按长度排序的微批次之后,短回合似乎学得更快。 原因: 逐微批次的token_mean给每个微批次相等的权重,于是一个 token 的权重是全局损失所分配权重的 倍(第 14.4 节)。按长度排序的微批次让 变化最大。 可选方案: (a) 按全局 token 数归一化(在数据并行中,于反向传播之前对计数做一次 all-reduce)。(b) 使用常数归一化因子(Dr. GRPO 的constant模式),它不需要全局计数,且与批次组成无关。(c) 等 token 数的微批次,这只能让误差变小。 建议做法: (a) 或 (b),并配一个单元测试,像第 14.4 节那样把累积梯度与单次传播的梯度相比较。下面的代码块打印按长度排序拆分下逐 token 的权重失真。
问题:打包的注意力泄漏。 现象: 打包训练运行无报错,但在第 0 步,训练器对数概率与采样器对数概率之间的比率就远离 1,裁剪比例(clip fraction)从第一个小批次(minibatch)起就很高,策略学得很慢或发散。 原因: 打包行上的因果掩码让每条序列都能关注排在它之前的序列,而贯穿整行的位置 id 让后面的每条序列都发生偏移;于是训练器在采样器从未见过的上下文下为 token 打分(第 14.1 节)。 可选方案: (a) 块对角掩码或变长注意力 kernel,并重置位置 id。(b) 不打包,承担填充的代价。 建议做法: (a),并配一个在真实轨迹上比较打包与未打包对数概率的测试。采样器自身的对数概率不需要任何参照运行就能让这个 bug 显形。
问题:浪费在零方差提示上的 rollout。 现象: 随着训练取得进展,零优势组的比例上升;每个有用梯度所需的 rollout 增加;学习曲线在留出通过率停止提升之前很久就变平了。 原因: 奖励全部相等的组在任何基线(baseline)下优势都为零,因此其中每个回合都是一次没有梯度的完整 rollout(第 9 章)。 可选方案: (a) 采样前的通过率过滤(第 14.5 节),它需要逐任务的估计,并可能让估计过时的任务得不到采样。(b) 动态采样,通过多采样来保持批次满额。(c) 对难任务使用更大的组,使罕见的成功能被采样到。 建议做法: (a),偶尔重访被排除的任务,并记录零优势比例。下面的代码块统计第 14.5 节的运行中落入零优势组的回合数。
问题:一条长尾序列主导内存。 现象: 在运行开始很久之后的随机步骤出现内存溢出错误,总是发生在收到最长轨迹的那个 rank 上;降低批大小会让它更少发生,但不会杜绝。 原因: 每个微批次固定序列条数时,填充后的内存是条数乘以最长成员的长度,而在长尾长度分布下,最长成员终将达到上下文上限。这时一个 8 条序列的打包或填充微批次的代价是上下文长度的 8 倍。 可选方案: (a) token 预算(第 14.2 节),把最长的轨迹单独放进微批次。(b) 分块并重新计算的对数概率(第 14.3 节),它降低了每 token 的成本,但没有改变其增长方式。(c) 带截断的硬长度上限,这会改变任务。 建议做法: (a) 与 (b) 结合,并根据在最大上下文长度下测得的峰值来设定预算。下面的代码块找出固定 8 条时最差的微批次,以及 token 预算为同一条轨迹分配的微批次。
小结
- 把 TinyAgent 的 64 个回合填充到最长回合,浪费了 74% 的计算位置。打包把各行填到 97%,但只有在使用块对角因果掩码、且位置 id 按序列重新开始时才正确:缺少它们,策略对数概率偏移最多 6.4 nats;有了它们,结果一致到 。
- 每个微批次固定序列条数会让内存与最长序列成正比;对于长尾长度分布,带长度排序的 token 预算把最大微批次从 524k token 降到 131k token,填充率为 3%。
- 在生产规模的词表下,logits 是长上下文 RL 中最大的内存项。在 Qwen3-0.6B 的输出层上测得:16k token 时完整计算为 29.9 GB,分块为 11.2 GB,分块加重新计算为 2.2 GB,且与长度无关。
- 使用逐微批次
token_mean的梯度累积会改变梯度:在 TinyAgent 上,随机拆分的相对误差为 8%,按长度排序的拆分为 52%。用全局 token 数归一化每个微批次(mode='constant')以 的精度复现了整批梯度。 - 按上一组的通过率过滤任务,在 60 步内把采样回合数减少了 43% 到 46%,留出通过率的差异在种子噪声范围内。对一次 8B 智能体运行的吞吐量算术表明,解码约占 GPU 小时的 60%,因为长上下文解码受内存带宽限制,token 速率大约只有训练的四分之一。
练习
- 为
pack_sequences和packed_logits编写一个单元测试,使其分别对第 14.1 节的两个 bug 各自失败。能暴露位置 bug 但不暴露掩码 bug 的最小序列对是什么? - 把
token_budget_batches扩展到打包微批次,此时成本是长度之和,而不是条数乘以最长长度。在 128k 预算下,那个 512 条轨迹的例子需要多少个微批次? - 对 个数据并行 rank、每个 rank 有各自的微批次的情况,推导逐微批次
token_mean下每个 token 的权重。证明按全局 token 数归一化只需要在反向传播之前对一个标量做一次 all-reduce,并用aggregate_loss实现它。 - 改变一个假设:重跑第 14.5 节,让过滤依据每个任务最近三组的平均值,而不是上一组。留出通过率的差距会消失吗?会多花多少个回合?
- 实现按与 成正比的概率采样,代替硬过滤,并与硬过滤比较所花的回合数和留出通过率。
- 针对 64k token 的上下文重做第 14.6 节的 GPU 小时估算。哪一项增长了,增长了多少?一个小 4 倍的 KV cache(例如来自更激进的分组查询注意力或 8 位 KV cache)能带来什么?
- 设计评审(design review):一位同事提议把微批次大小从 4 条序列提高到 16 条,以提高训练 MFU。这次运行使用带长尾的 32k token 智能体轨迹,并采用逐微批次的损失归一化。列出除 MFU 之外会改变的三件事,以及你会针对每件事要求的测量。