verl RL Dataflow
RL 训练 · verl · commit 7de8f2e

verl 的 RL 数据流:一个 batch 怎样从 rollout 走到 PPO 更新

一步 RL 里,同一个 batch 要同时承载 per-token 张量、per-sample 对象和 per-step 元信息。 DataProto 把这三类数据分成三层,reward 则被压成一个标量,写在每条序列最后一个有效 token 上(有效长度 Li>0 时)。 本页沿着 ray_trainer.py 的 fit() 顺序,拆开 DataProto、reward、advantage 和 metrics 之间的数据流。

TL;DR · 30 秒抓住核心

DataProto 不是一个新的 tensor 格式,它把 TensorDict、object ndarray 字典和 meta_info 拼在一个壳里。 三层的切法不同:tensor 和 non_tensor 按 batch 维切,meta_info 原样广播给每个分片(verl/protocol.py:861-900)。

reward 在默认路径上是一条序列一个标量。Li>0 时它被写在最后一个有效 response token 上,其余位置为 0;Li=0 时索引 -1 会写进末列(见 01)。因此 token_level_scores.sum(-1) 仍能还原 reward (verl/experimental/reward_loop/reward_manager/base.py:76-81、verl/trainer/ppo/metric_utils.py:453)。

loss 的默认聚合是 token-mean:分母不是 batch size,也不是最大序列长度,而是全局有效 response token 数。 FSDP 引擎把每个 rank 的 loss_mask.sum() 做一次 SUM all-reduce,再交给 agg_loss (verl/workers/engine/fsdp/transformer_impl.py:672-676、verl/trainer/ppo/core_algos.py:1170-1175)。

指标在 fit() 尾部统一结算:compute_data_metrics、compute_timing_metrics、compute_throughout_metrics 各自选分母;同名 std 在 GRPO 和 validation 里用的是不同的估计量(verl/trainer/ppo/metric_utils.py:974、verl/trainer/ppo/core_algos.py:321)。

3 层
DataProto 的三个字段
batch / non_tensor_batch / meta_info
1 个 token
默认 manager 下每条序列只有 1 个 reward 写入位置
Li>0 时为最后一个有效 token;Li=0 时索引 -1 写末列
1/N
token-mean 的每 token 权重
N = 全局有效 response token 数
01

一步 RL 的 batch 同时装着张量、对象和元信息,reward 却只是一个标量

读完这节你应该知道:为什么 DataProto 要分三层,以及「reward 放在哪个 token 上」为什么会直接影响 loss 和 metrics。

先看一步训练里 batch 需要装什么。模型前向和 loss 需要 per-token 的 responses、attention_mask、response_mask、 old_log_probs、ref_log_prob、values、advantages、returns; reward 和验证需要 per-sample 的 uid、data_source、reward_model;调度和日志还需要 per-step 的 temperature、global_steps、global_token_num。这三类数据的形状和生命周期都不一样。

最关键的约定是 reward 的落点。默认路径把一条序列的 outcome reward 压成一个 float,写在最后一个有效 response token 上(仅当 Li>0): 其余 token 为 0。Li=0 时索引 valid_response_length - 1 = -1 会写到末列(padding 位置),这是源码的边界行为。 这样 token_level_scores.sum(-1) 仍能还原成每条序列的标量 reward (verl/trainer/ppo/metric_utils.py:453)。

token_level_scores[i, Li − 1] = ri(Li>0;Li=0 时索引为 −1),其余位置为 0
Σt token_level_scores[i, t] = ri
Li 是第 i 条序列的有效 response 长度,由 attention_mask 的 response 段求和得到 (verl/experimental/reward_loop/reward_manager/base.py:77-80)。Li=0(空 response / abort)时索引 −1 写到末列,而不是「最后一个有效 token」。

这个落点不是固定的 R − 1,而是 Li − 1。因为 batch 里的 response 是右填充的,不同序列的有效长度不同; 如果把 reward 写在固定末列,短序列的 reward 会落进 padding。batch 路径的 assemble_rm_scores 用 valid_response_length - 1 做 scatter(base.py:79);单条轨迹路径在 as_dict 里直接写 rm_scores[-1], 因为那条轨迹还没有被 batch padding(verl/experimental/agent_loop/agent_loop.py:147-148)。两条路径的索引写法不同,但在 Li>0 时都指向「最后一个有效 token」; Li=0 时 batch 路径的索引 −1 会写到末列(padding 位置),这是源码的边界行为。

这也解释了 metrics 为什么容易读错。同一个 batch,response_length/mean 会把 abort 的 0 长度样本算进去, response_length_non_aborted/mean 则把它们排除 (verl/trainer/ppo/metric_utils.py:573、:581)。分母不同,两个数就不是同一个东西。 后面的 03 讲 DataProto 怎么保证三层对齐,04 讲 token-mean 的分母,05 讲一步的调用顺序,06 讲每个指标的分母。

02

两条 reward 路线都产出 (B, R) 的 rm_scores,差别在谁来算分/提供 score

读完这节你应该知道:逐样本打分和批量 reward model 各自适合什么场景,以及 trainer 在什么条件下会跳过其中一条。

路线 A 是逐样本打分。reward loop 的 worker 在 compute_score_batch 里为每个样本创建一个 asyncio task, 调用 self.compute_score(verl/experimental/reward_loop/reward_loop.py:138-155):默认规则 reward 或自定义 reward function 走 run_single(:148、:155),启用 reward model 时走 compute_score_disrm(:153)。 默认的 NaiveRewardManager 只解码 response(prompt 不参与打分),读取 data_source 和 ground_truth,调用 compute_score,返回一个 {"reward_score": ..., "reward_extra_info": ...}(verl/experimental/reward_loop/reward_manager/naive.py:34-99)。 更早的 legacy 版本在 verl/workers/reward_manager/naive.py:90-176 里逐条 for i in range(len(data)), 它会同时解码 prompt 与 response;它的 docstring 明确说:一次慢的 compute_score 会阻塞整个训练循环,所以加了 SIGALRM 超时保护 (naive.py:32-40)。

路线 B 是批量 reward model。trainer 侧的 compute_rm_score 先把 batch 补到 worker 数的整数倍, 再 chunk(num_workers) 分给各个 worker,收集后按原始长度截断,最后把 scores 装配成 (B, R) 的 rm_scores (verl/experimental/reward_loop/reward_loop.py:343-363)。装配同样是「每行一个写入位置」: rm_scores[arange(B), valid_response_length - 1] = scores(base.py:76-81)。

trainer 只在一种情况下会走路线 B:use_rm 为真且 batch 里还没有 rm_scores (verl/trainer/ppo/ray_trainer.py:1563-1565)。如果 agent loop 已经把 rm_scores 放进 rollout 输出, trainer 直接调用 extract_reward 读出来(:1568;verl/trainer/ppo/reward.py:160-167)。 两条路线最终都落到同一个字段,DataProto 不需要知道 reward 是谁算的。

表 1 · reward 的两条落点(页面整理;出处见各单元格)

路线输入 → 输出触发条件代价源码
逐样本 compute_score DataProtoItem → 一个 float,写入最后有效 token(Li>0;Li=0 时写末列) reward worker handle 可用:RM 关闭,或 RM 使用独立 resource pool(reward_loop.py:320-322) 每个样本一个 task;单样本慢会拖住一条轨迹,legacy 版本有超时保护 reward_loop.py:138-155;experimental/.../naive.py:34-99
批量 reward model padded batch → (B, R) 的 rm_scores use_rm 为真且 batch 内没有 rm_scores(ray_trainer.py:1563-1565) 补到 worker 数整数倍后分片;默认 8 个 worker(reward.yaml:5) reward_loop.py:343-363;base.py:76-81

两条路线之外,还存在一条「复用」路径:如果 batch 里已经有 rm_scores,trainer 不会重新算 (ray_trainer.py:1563)。这也是为什么 reward model 和 rule-based reward 可以在同一个 trainer 里切换: 切换的是谁写 rm_scores,不是下游 loss 和 metrics 的读法。

03

三层各有各的切法:tensor 和 object 按行切,meta_info 原样广播

读完这节你应该知道:DataProto 的哪个字段会被 chunk/split 切开,哪个字段在每个分片里保持同一份,以及 union 为什么不会静默覆盖。

DataProto 的定义只有三个字段:batch 是 TensorDict,non_tensor_batch 是 dict[str, np.ndarray], meta_info 是普通 dict(verl/protocol.py:317-328)。 check_consistency 强制 tensor 的 batch 维只有一维;batch is not None 且 non_tensor_batch 非空时, non_tensor 的每个数组必须是 numpy.ndarray,且 shape[0] 等于 batch size(verl/protocol.py:451-474)。

batch : TensorDict,dim0 是 batch
non_tensor_batch : dict[str, np.ndarray],shape[0] = batch
meta_info : dict,per-step,不按样本切
三个字段的语义;字段定义在 verl/protocol.py:326-328,一致性检查在 :451-474。

chunk:前两层按行切,meta_info 原样传给每个分片

chunk(chunks) 先在 dim0 上切 TensorDict,再按同样的 chunk 边界用 np.array_split 切每个 non_tensor 数组, 最后把同一个 meta_info 传给每个输出(verl/protocol.py:861-900)。如果没开 auto padding, 它还要求 len(self) % chunks == 0,否则直接断言失败(:870-872)。

这种设计让 temperature、global_steps 这类 per-step 标量不会在切分时被复制成 B 份 (ray_trainer.py:1479、:1489),也让 uid、data_source、reward_model 这些 per-sample 对象跟着行一起走。切分语义由字段决定,不需要每个调用点自己记。

其它算子:select / reorder / repeat 都同时作用于两层

select_idxs 用一个索引数组同时索引 TensorDict 和每个 non_tensor 数组(verl/protocol.py:632-670); reorder 在原地重排两层(:960-966);repeat 在 interleave=True 时用 repeat_interleave/np.repeat,interleave=False 时 tensor 走 expand+reshape、non_tensor 走 np.tile, 两条分支都保持行对应(:968-1010,np.tile 在 :1001-1004)。split(split_size) 只是按切片反复调用 __getitem__(:902-911)。这些算子都不碰 meta_info,或者只把同一个 dict 传下去。

union:同名 key 必须相等,否则报错而不是覆盖

一步 RL 会多次调用 batch.union(new_tensor):rollout 输出、old_log_probs、ref_log_prob、values 都是这样并进 batch 的。 union 分别合并三层(verl/protocol.py:778-796),每一层都有自己的相等性检查:

这带来一个容易忽略的行为:union 是原地修改左操作数并返回 self (verl/protocol.py:792-795)。batch = batch.union(x) 看起来像纯函数,实际会改掉原来的 batch; 如果别处还持有同一个引用,会一起看到新 key。

padding:重复样本凑整除,collect 后再把尾部切掉

pad_dataproto_to_divisor 在长度不能被整除时,从 batch 头部取样本重复,拼到尾部,返回 pad_size (verl/protocol.py:85-99)。dispatch 层把 pad_size 记进 kwargs(verl/single_controller/base/decorator.py:91-117), worker 返回后 collect 层用 output.select_idxs(indices[:-padding_count]) 把尾部切掉 (verl/single_controller/ray/base.py:53-63)。所以对调用方来说 padding 是透明的; 但对 worker 内部计算的 per-sample 指标,重复样本仍然参与过统计(这一点在 07 展开)。

一句话判据

先问「这个字段是 per-token、per-sample 还是 per-step」

per-token 放 batch,跟着 dim0 和序列维走;per-sample 放 non_tensor_batch,跟着 dim0 走; per-step 放 meta_info,不切分、原样广播。选错层,chunk 和 union 的行为就会和预期不一致。

04

token-mean 的分母是全局有效 token 数,不是 batch size,也不是序列长度

读完这节你应该知道:response_mask 怎么从 attention_mask 来、全局 token 分母怎么在 DP 之间对齐、reward 打分为什么先补齐再分片。

loss 的 token 权重来自 response_mask。trainer 先取 response 张量的宽度 R,再切 attention_mask[:, -R:](verl/trainer/ppo/ray_trainer.py:132-135)。 response 张量是右填充的,模型输出回到 padding 布局时也在右侧补零(verl/workers/utils/padding.py:141-144), 所以最后 R 列正好是 response 段。到了 engine 里,loss_mask 直接等于 response_mask (verl/workers/utils/padding.py:77)。

agg_loss 的 token-mean 分支做的是「masked sum ÷ 有效 token 数」: 如果 batch_num_tokens 没给,它就用本地 loss_mask.sum();当 dp_size > 1 时,它要求调用方传入全局 batch_num_tokens,否则直接抛错(verl/trainer/ppo/core_algos.py:1170-1175)。

Ltoken-mean = Σi,t mi,t · ℓi,t / Σall ranks Σi,t mi,t
lossrank = local_sum / global_N × dp_size
m 是 response_mask;global_N 是全局有效 token 数。源码:core_algos.py:1170-1175。

这个 global_N 不是 trainer 手算的。FSDP engine 在每个 rank 上先算 batch_num_tokens = data["loss_mask"].sum(),再对 DP 组做一次 ReduceOp.SUM,最后写回 TensorDict 的 non-tensor 区 (verl/workers/engine/fsdp/transformer_impl.py:672-676)。 推断(由 core_algos.py:1175 的 * dp_size 与 DDP 的梯度平均语义推出): 每个 rank 的 loss 先乘 dp_size,DDP 再对梯度取平均,两步合起来等价于用全局 token 数做一次平均。

分片还有一层:_balance_batch 按每条序列的 workload 把所有序列重新排到各个 DP rank,使每个 rank 的总负载接近 (verl/trainer/ppo/ray_trainer.py:1182-1250)。workload 不是原始 token 数,而是 24576 * seqlen + seqlen²(verl/utils/seqlen_balancing.py:27-46),用 attention 的线性项与二次项近似 FLOPs。 它只改顺序,不改 token 总数;代码注释也说明这会改变 mini-batch 的组成,但 advantage 按 uid 计算,不受顺序影响 (:1546-1548)。

reward 打分的 worker 池:先补齐,再分片,最后按原长度截断

默认配置开 8 个 reward worker(verl/trainer/config/reward/reward.yaml:5)。 compute_rm_score 把 batch 补到 worker 数的整数倍,chunk 成等长分片,每个 worker 并发处理, 收集后把 padding 部分截掉(verl/experimental/reward_loop/reward_loop.py:343-358)。 这和 03 节的 DataProto padding 是同一套思路:先凑整除,再把尾部丢掉。

在默认的规则 reward 路径上,agent loop 会从可用的 reward worker handle 里随机选一个,远程调用 compute_score, 把返回的 reward_score 写进轨迹输出(verl/experimental/agent_loop/agent_loop.py:1113-1115)。 handle 只在「RM 关闭」或「RM 使用独立 resource pool」时返回(reward_loop.py:320-322); 否则 reward 由 trainer 在 rollout 之后统一计算(ray_trainer.py:1563-1565)。

表 2 · 本 commit 的默认开关(页面整理;出处见各行)

配置默认值源码
algorithm.adv_estimatorgaeppo_trainer.yaml:78
algorithm.use_kl_in_rewardFalseppo_trainer.yaml:102
actor.use_kl_lossfalseactor.yaml:103
actor.clip_ratio0.2actor.yaml:36
actor.loss_agg_modetoken-meanactor.yaml:86
actor.entropy_coeff0actor.yaml:93
rollout.n1rollout.yaml:135
reward.reward_managerregister / naivereward.yaml:18-19
reward.num_workers8reward.yaml:5

下面把这个 batch 的 token 账本做成可调参数的模拟。B 是 prompt 数,n 是 rollout.n,P 是 prompt 长度,R 是 response 长度。 它展示五个量:序列数 B×n、有效 response token 数、rm_scores 写入位置数、总 token 数,以及 token-mean 的每 token 归一化权重。

序列数 B×n
4
有效 response token
12
rm_scores 写入位置数
4
总 token(P+R)×序列
28
sum(global_token_num)(attention_mask 求和)
28 token
token-mean 每 token 归一化权重
0.083333 = 1 / 12

默认参数是页面自定的 B=2、n=2、P=4、R=3;默认 rollout.n 是 1(rollout.yaml:135)。 模拟假设每条 response 都写满 R 个 token、没有 EOS 提前停止、没有 padding、没有截断。它是简化计算,不是源码逐行复现。

手算两组:B、n、P、R 变化下的 token 账本点开看每一步

组 A(默认):B=2,n=2,P=4,R=3

  1. 序列数 = B × n = 2 × 2 = 4。
  2. 有效 response token 数 = 4 × R = 4 × 3 = 12。
  3. 总 token 数 = 4 × (P + R) = 4 × (4 + 3) = 28。
  4. token-mean 权重 = 1 / 12 = 0.083333。
  5. rm_scores 写入位置数 = 每条序列 1 个 = 4。

组 B:B=3,n=1,P=5,R=2

  1. 序列数 = 3 × 1 = 3。
  2. 有效 response token 数 = 3 × 2 = 6。
  3. 总 token 数 = 3 × (5 + 2) = 21。
  4. token-mean 权重 = 1 / 6 = 0.166667。
  5. rm_scores 写入位置数 = 每条序列 1 个 = 3。

JS 只做整数乘加和 1/resp,显示时用 toFixed(6);上面两组参数在页面上逐位一致。 权重的分子 1 来自 agg_loss 的 token-mean 分支(core_algos.py:1175), 写入位置的 1 来自 assemble_rm_scores 的单点 scatter(base.py:79)。

这个模拟覆盖了什么、没覆盖什么覆盖分支与差异清单

覆盖:B×n 的序列数;每序列一个 reward 写入位置;token-mean 的 1/有效 token 数; 总 token 数按 attention_mask 求和的口径。

未覆盖:空 response / abort(Li=0,索引 −1 写末列);EOS 提前停止导致的有效长度差异;右填充与 response_mask; DP 分片与 dp_size 缩放;mini-batch 划分;KL 惩罚、clip、entropy 对 loss 的额外项; reward model 的分数分布与超时。真实 global_N 由 FSDP engine 的 all-reduce 得到 (transformer_impl.py:672-676),不是用 P、R 直接乘出来的。

05

一步的顺序是采样→打分→重算→参考→优势→critic→actor,指标在尾部统一结算

读完这节你应该知道:每个阶段往 DataProto 里加了哪些 key,哪些阶段是可选的,以及 metrics 在什么时候被收集。

01 采样 repeat + rollout responses · response_mask · rollout_log_probs agent loop 默认在这里写入 rm_scores(1 个写入位置/序列) rollout/* 生成长度、接受率等 02 打分 reward → score reward_tensor → token_level_scores 每条序列一个标量,写在最后一个有效 response token(L>0;L=0 时索引 -1 写末列) critic/score/* 尾部才汇总 03 重算 actor logprob old_log_probs · entropys entropy 先算指标,再从 batch 里 pop 掉 actor/entropy token-mean 聚合 04 参考 ref logprob ref_log_prob(KL 开启时才计算) 默认 use_kl_in_reward=False、use_kl_loss=false actor/reward_kl_penalty 仅 KL in reward 路径 05 优势 GAE / GRPO token_level_rewards → advantages / returns 先接 reward,再按需减 β·KL,最后算 advantage critic/advantages/* response_mask 内统计 06 critic value loss values → vf_loss / vf_clipfrac critic 更新后,batch 里的 advantages 不变 critic/vf_loss critic 前缀由 rename 加上 07 actor policy loss pg_loss / pg_clipfrac / ppo_kl / grad_norm mini-batch 内多次前向,指标由 actor/ 前缀 rename actor/pg_loss 最后统一写入 logger 尾部统一结算:compute_data_metrics / compute_timing_metrics / compute_throughout_metrics / variance proxy 可选项:ref_log_prob(KL 开关)、values(critic 开关)、validation(test_freq 触发)
图 1 · 按 verl/trainer/ppo/ray_trainer.py 的 fit() 调用顺序整理的七段数据流。 图中把「reward 已经在 agent loop 写入」当作默认路径;若 use_rm=True 且 batch 里没有 rm_scores, trainer 会在 02 打分步内、extract_reward 之前调用 _compute_reward_colocate(ray_trainer.py:1561-1568)。
  1. 采样:trainer 从 dataloader 取一个 prompt batch,加 uid,按 rollout.n 重复后交给 rollout (ray_trainer.py:1478-1491)。rollout 输出 responses、response_mask、rollout_log_probs; 默认路径下 agent loop 还会把 reward 写进 rm_scores(agent_loop.py:122-149)。
  2. 对齐:原始 prompt batch 也按 n 重复,然后与 rollout 输出 union (ray_trainer.py:1540-1541)。如果 batch 里没有 response_mask,就从 attention_mask 末尾切一段补上 (:1543-1544)。
  3. 打分:若 use_rm 为真且没有 rm_scores,先调用 reward model;随后 extract_reward 取出 reward_tensor 和额外信息(ray_trainer.py:1561-1568)。
  4. 重算 old_log_probs:actor 对当前 batch 前向,返回 old_log_probs 和 entropys; entropy 指标先算,再从 batch 里 pop 掉(ray_trainer.py:1585-1601)。
  5. 参考 log prob:只有 use_reference_policy 为真时才计算。它由 use_kl_in_reward 或 use_kl_loss 触发(verl/trainer/ppo/utils.py:75-79),结果 union 进 batch (ray_trainer.py:1618-1622)。
  6. values:只有 use_critic 为真时,critic 前向返回 values 并 union 进 batch (ray_trainer.py:1625-1628)。默认 adv_estimator=gae,所以 critic 默认开启 (verl/trainer/ppo/utils.py:96-101)。
  7. 优势:先把 reward_tensor 写成 token_level_scores,把 reward 额外信息写进 non_tensor (ray_trainer.py:1633-1636);如果开了 KL in reward,就减 β·KL(old‖ref),否则 token_level_rewards = token_level_scores(:1639-1645)。最后调用 compute_advantage (:1667-1675)。
  8. 更新与结算:critic 更新(:1677-1681)、actor 更新(:1689-1690), 然后在尾部收集 data / timing / throughput / variance / spec 指标并 logger.log(:1764-1802)。

顺序上有两个容易看错的地方。第一,reward 的计算发生在 old_log_prob 之前,但 token_level_scores 是在优势阶段才写进 batch (ray_trainer.py:1561-1568 对 :1633)。第二,entropy 是 old_log_prob 阶段算完就 pop 掉的临时字段 (:1601),后续的 actor 更新不会从 batch 里再读到它。

KL 的位置也要分清。KL in reward 用的是 old_log_probs 和 ref_log_prob (ray_trainer.py:100-102),在优势之前执行;KL in loss 则是在 actor 的 mini-batch 内用当前 log_prob 和 ref_log_prob 计算(verl/workers/utils/losses.py:172-182)。两者默认都关闭 (ppo_trainer.yaml:102、actor.yaml:103)。

06

每个指标都能追到分子和分母;同名 std 在训练和验证里不是同一个估计量

读完这节你应该知道:reward、response_length、clipfrac、KL、grad_norm、MFU、throughput 分别怎么算,以及 validation 的 @N 指标为什么不能和训练指标混着读。

指标分两批进来。第一批是各阶段就地写入 metrics dict 的标量,例如 old_log_prob 阶段的 actor/entropy(ray_trainer.py:1596-1600)、KL 阶段的 actor/reward_kl_penalty(:1643)、critic 和 actor 的返回值(:1680、:1718)。 第二批是尾部对整个 batch 的统计:compute_data_metrics(:1772)、 compute_timing_metrics(:1783)、compute_throughout_metrics(:1786)。

表 3 · 常用指标 key 与分母(页面整理;出处见各行)

key分子 / 分母源码
critic/score/mean 每条序列 token_level_scores.sum(-1) 的均值;只统计非 abort 样本 metric_utils.py:453、:471-475
critic/rewards/mean 每条序列 token_level_rewards.sum(-1) 的均值;同样排除 abort metric_utils.py:454、:482-485
critic/advantages/mean response_mask 选中的 advantage 元素均值 metric_utils.py:490-495
response_length/mean attention_mask[:, -R:].sum(-1) 的均值;包含 abort 的 0 metric_utils.py:81-87、:573
response_length/clip_ratio response_length == max_response_length 的比例 metric_utils.py:576-578
actor/pg_clipfrac 被 clip 的 token 占 response_mask 有效 token 的比例 core_algos.py:1353
actor/ppo_kl -(log_prob - old_log_prob) 先把差值 clamp 到 [-20, 20],再在 response token 上做 masked mean core_algos.py:1336-1340
actor/entropy agg_loss(entropys, response_mask),默认 token-mean ray_trainer.py:1590-1597
actor/grad_norm engine 返回的 grad_norm,由 actor/ 前缀 rename;不做额外 all-reduce engine_workers.py:236-249、ray_trainer.py:1371
perf/throughput sum(global_token_num) / (step_time × n_gpus) metric_utils.py:679-687
perf/mfu/actor estimated_flops / promised_flops / world_size;forward-only 再除 3 engine_workers.py:264-270、ray_trainer.py:1373

下面把其中三个 key 缩成两条序列的模拟:response_length/mean、response_length/clip_ratio 和 perf/throughput。P 是 prompt 长度,L 是最大 response 长度,r1 / r2 是两条序列的有效 response 长度, t 是 step 时间,g 是 GPU 数。总 token 数按 2×P + r1 + r2 计算。

response_length/mean
6.50
response_length/clip_ratio
0.500
perf/throughput(token / s / GPU)
1.313

这是两条序列的简化模拟:mean 取 (r1+r2)/2,clip 取 r_i == L 的比例,throughput 取 (2P+r1+r2)/(t×g)。交互把 r1/r2 钳制在 L 以内(对应 r_i ≤ max_response_length); 它不复现真实 batch 的 padding、abort 和 DP 分片。

手算两组:长度、clip 与 throughput结果保留 2/3 位小数,JS 与手算同口径

组 A(默认):P=4,L=8,r1=8,r2=5,t=2,g=8

  1. 总 token = 2×P + r1 + r2 = 2×4 + 8 + 5 = 21。
  2. response_length/mean = (8 + 5) / 2 = 6.50。
  3. clip_ratio = 1 / 2 = 0.500;只有 r1 等于 L。
  4. throughput = 21 / (2×8) = 21 / 16 = 1.3125,页面显示 1.313。

组 B:P=3,L=4,r1=4,r2=4,t=1,g=2

  1. 总 token = 2×3 + 4 + 4 = 14。
  2. response_length/mean = (4 + 4) / 2 = 4.00。
  3. clip_ratio = 2 / 2 = 1.000;两条都等于 L。
  4. throughput = 14 / (1×2) = 7.000。

JS 用 toFixed(2) 显示 mean,toFixed(3) 显示 clip 和 throughput;中间值不取整。 组 A 的 1.3125 在显示时才变成 1.313。

这个模拟覆盖了什么、没覆盖什么覆盖分支与差异清单

覆盖:两条序列的 mean;response_length == max_response_length 的 clip 计数; total_tokens / (time × n_gpus) 的 throughput 口径。

未覆盖:abort 样本(response_length == 0);response_length_non_aborted/*; prompt 长度分布;DP/TP 划分;global_token_num 的实际来源;per-token timing 的分母差异; validation 的 @N 聚合。真实 response_length/mean 由 batch 内所有序列求均值 (metric_utils.py:573),不是两条序列的手算。

同名 std:GRPO 用样本标准差,validation 用总体标准差

GRPO 的 advantage 归一化在 core_algos.py 里用 torch.std(scores_tensor) (verl/trainer/ppo/core_algos.py:321)。PyTorch 的 torch.std 默认做 Bessel 校正,分母是 N − 1;当组内只有一个样本时,代码直接把 std 设为 1.0(:315-317)。

validation 的 std@N 走的是另一条路:process_validation_metrics 里 np_std = np.std (verl/trainer/ppo/metric_utils.py:974),对每个 uid 的 N 个样本,仅在 N>1 时才计算 float(np_std(var_vals))(:1019-1020)。numpy.std 默认不做 Bessel 校正,分母是 N。 GRPO 归一化用样本标准差(torch.std,ddof=1);validation 的 std@N(仅 N>1 时计算;np.std,ddof=0)不是同一个估计量,直接比较会得到不可解释的差。

validation 按 uid 聚合,再按 data_source 分组

_validate 先把每个验证样本按 val_kwargs.n 重复(ray_trainer.py:617-619), 收集 uid(:670)和 data_source(:691)。 process_validation_metrics 按 (data_source, uid) 分组(metric_utils.py:948-953), 对每个 uid 的 N 个样本先算 mean@N(:1017);仅当 N>1 时再算 std@N(:1019-1020)。 N>1 时,用 bootstrap 算 best@N / worst@N(:1027-1040);如果样本里有 pred, 还算 maj@N(:1042-1057)。bootstrap 次数 1000(:976),只在这个 N>1 分支执行; 默认 val_kwargs.n=1(rollout.yaml:175)时,只有 mean@1,没有 std / best / worst / maj 这类 @N 指标。 最后对 uid 取平均(:1062-1073)。

_val_metrics_update 把 acc 优先当作 core variable,没有 acc 才用 reward (ray_trainer.py:724-725);只有 mean/maj/best 的 @n_max 结果会进 val-core, 其余进 val-aux(:729-738)。这决定了日志里哪些 validation 指标会被高亮。

reduce_metrics:先按 Metric 的显式 aggregation,再回退到 key 子串

actor 和 critic 的 worker 会返回一组 micro-batch 指标列表。worker 先对包装成 Metric 的列表调用 Metric.aggregate_dp,按对象自带的显式 aggregation(MEAN / SUM / MIN / MAX)在 DP 之间聚合 (verl/workers/engine_workers.py:358-368;aggregate_dp 定义在 verl/utils/metric/utils.py:139-156)。trainer 侧的 reduce_metrics 先判断值是不是 Metric: 是就调用 val.aggregate(),按显式 aggregation 聚合(verl/utils/metric/utils.py:49-51、:123-136); 其余未包装成 Metric 的值才按 key 子串回退——含 max 用 np.max,含 min 用 np.min, 否则用 np.mean(:52-57)。所以给指标改名时,要先区分它是否已经是 Metric;纯列表形式的指标才依赖名字里的子串。

07

DataProto 把 batch 放在 driver 上;它保证对齐,但不保证免费

读完这节你应该知道:哪些行为是 DataProto 的保证,哪些是默认路径的假设,以及换 reward / 换 trainer 时最先要检查什么。

一句话判据

先确认三件事,再读训练日志

第一,reward 是哪条路径写的:agent loop 的 rm_scores,还是 trainer 的 reward model; 第二,loss 的分母是全局 token 数还是本地 token 数(dp_size > 1 时必须用全局数); 第三,指标是训练 batch 的还是 validation 的 @N 聚合。三件事里任何一件换了,数字都不能直接横比。