
RLHF 的吞吐瓶颈不是固定在 rollout 或 update 其中一侧,而是在同步闭环里由最长窗口和互等关系共同决定。 从第25篇的时间账继续推进,回答 RLHF 吞吐下降时该如何归因。文章不把瓶颈简单归到 rollout 或 update,而是用 perf/throughput、 timing_s/gen、update_actor、old_log_prob、update_weights 和 agent loop 指标建立诊断路径,适合带着日志定位真实等待窗口
第 25 篇建立了一轮 RL step 的 profiling 账本:先看 perf/time_per_step、perf/throughput,再拆 timing_s/gen、timing_s/old_log_prob、timing_s/update_actor、timing_s/update_weights。第 26 篇继续往前走一步:当吞吐下降时,怎样判断问题到底在 rollout generation,还是 actor update,或者在它们之间的同步边界?
本文的核心判断是:在 verl 的同步 PPO 主循环里,端到端吞吐由 total_tokens / (step_time * n_gpus)定义,但 step_time是一串阶段窗口的组合。gen代表 trainer 等 rollout 产样本;old_log_prob是训练侧重新跑 actor inference;update_actor是训练 engine 的 mini-batch/micro-batch update;update_weights是新权重回到 rollout 的同步。只有把这些窗口和资源状态一起读,才能判断“慢”属于 rollout、update、sync,还是批次形态带来的误导。
先看第 26 篇的诊断地图。读图时注意:吞吐公式在右侧很简单,但左侧的 step time 并不是单个模块时间;任何一个阶段变长,都会拉低端到端 tokens/s/GPU。

RLHF 吞吐瓶颈诊断地图
这张图的作用,是把第 26 篇和第 25 篇衔接起来:我们不从“rollout 一定慢”或“训练一定慢”出发,而是先把 perf/throughput拆回 timing_s/*。后文会依次解释 rollout-bound、update-bound 和 sync-bound 三种读法,并补上几个常见的误判边界。
verl 的 compute_throughout_metrics()把吞吐写得很直接:先从 batch.meta_info["global_token_num"]求总 token 数,再用 timing_raw["step"]和 GPU 数计算 perf/throughput。这意味着 throughput 下降只有两个直接原因:同样 GPU 数下,总 token 少了,或者 step time 长了。绝大多数瓶颈定位都要先回到第二项。
下面这张图把吞吐指标拆回 step time。读图时注意,perf/throughput是端到端指标,它本身不会告诉你是哪一段慢;真正的归因要看 timing_s/gen、timing_s/update_actor、timing_s/update_weights等明细。

从 throughput 回到 step time
源码上,trainer 在生成后写入 batch.meta_info["global_token_num"],最后调用 compute_timing_metrics()和 compute_throughout_metrics()统一产出指标(verl/trainer/ppo/ray_trainer.py:1417-1418、1639-1642)。compute_timing_metrics()给每个阶段输出 timing_s/{name},compute_throughout_metrics()则用 step作为分母(verl/trainer/ppo/metric_utils.py:271-346)。
所以第一条读法是:先不要比较不同实验的裸 throughput,而要同时看 token 数、response 长度、rollout.n、step time 和阶段明细。比如 response 变长会让 total tokens 增加,也会让 gen、old_log_prob和 update_actor都变长;如果只看吞吐数字,很容易把正常的 token 结构变化误判成系统退化。
第 26 篇讨论的是当前同步 PPO 主路径,不是 fully async policy。这个边界很重要:在 RayPPOTrainer.fit()里,gen返回后,trainer 会调用 checkpoint_manager.sleep_replicas(),随后继续 reward、old logprob、adv、update actor,最后通过 checkpoint_manager.update_weights()把新权重送回 rollout。也就是说,rollout generation 和 actor update 在这条主路径上不是完全重叠的流水线。
下面这张图画的是资源时间线。读图时注意:rollout engine 活跃在 gen窗口;训练 worker 活跃在 logprob/update 窗口;update_weights把训练后的 actor 接回 rollout。这个串行结构决定了瓶颈不是“谁的 GPU 利用率高”,而是谁让下一段等得更久。

同步 PPO 的 rollout、update 与 sync 时间线
源码支持这个时间线:gen阶段调用 async_rollout_manager.generate_sequences(),紧接着让 rollout replicas sleep;后续 old_log_prob、update_actor和 update_weights都在同一个 step计时窗口内顺序执行(verl/trainer/ppo/ray_trainer.py:1373-1586)。CheckpointEngineManager.sleep_replicas()会让所有 rollout replicas sleep,update_weights()在 naive 路径直接更新 colocated worker,在非 naive 路径还会 abort、release KV cache、build process group、双侧 update、finalize、resume generation(verl/checkpoint_engine/base.py:410-492)。
这带来一个工程解释:如果 timing_s/gen很高,训练 worker 可能在等样本;如果 timing_s/update_actor或 timing_s/update_weights很高,rollout 侧可能在睡眠或等待新权重。第 29 篇会讨论 fully async 如何改变这种互等关系;第 26 篇先把同步路径读清楚。
timing_s/gen当 timing_s/gen占 step time 的大头时,第一反应通常是“rollout 慢”。但第 25 篇已经说过,gen是一个外层等待窗口,它可能包含 LLM server generation、agent loop 工具调用、异步 reward、Ray worker 汇总、response 长尾和 sleep 时机。要判断是不是 rollout-bound,需要继续看 agent loop 的内部指标。
下面这张图给出 rollout-bound 的证据链。读图时注意:左边是外层 gen,右边是 agent loop manager 聚合出的 min/max/mean 和 slowest sample。只有这两层一致,才能更有把握地把慢点归到 rollout 侧。

rollout-bound 的证据链
在源码里,AgentLoopManager.generate_sequences()会把输入 batch 切给多个 agent loop worker,等待所有 worker 返回后 concat,再从每个输出的 meta_info["metrics"]中计算 agent_loop/generate_sequences/min|max|mean、tool_calls/min|max|mean、compute_score/min|max|mean,并记录 slowest sample 的 prompt/response length(verl/experimental/agent_loop/agent_loop.py:1069-1126)。单轮 agent loop 对 server request 用 simple_timer("generate_sequences")计时;tool agent loop 还会单独记录 tool_calls(verl/experimental/agent_loop/single_turn_agent_loop.py:57-70,verl/experimental/agent_loop/tool_agent_loop.py:216-285)。
因此,rollout-bound 的典型证据不是一句“gen高”,而是:
timing_s/gen / timing_s/step占比高。agent_loop/generate_sequences/max或 agent_loop/slowest/response_length指向长尾样本。agent_loop/tool_calls/max、compute_score/max或 num_preempted/max能解释多轮工具、reward 或推理调度压力。timing_s/update_actor相对稳定,说明训练 update 本身没有同步变慢。如果只满足第一条,结论要保守。gen还可能被 controller 汇总、DataProto 合并、Ray 调度或 sleep/resume 影响;这些会在第 27、28 篇进一步拆。
update_actor和 MFU另一种常见情况是 timing_s/update_actor占比高。这时瓶颈更接近训练 engine,但仍然不能只看总时间。_update_actor()会把 batch 转成 TensorDict,转成 no-padding 表示,写入 ppo_mini_batch_size、ppo_epochs、shuffle 和 loss 计算开关,再调用 actor_rollout_wg.update_actor()。worker 侧会进入 ActorRolloutRefWorker.update_actor(),再调用 TrainingWorker.train_mini_batch()和 train_batch()。
下面这张图把 update-bound 的路径拆开。读图时注意:update_actor高可能来自算法层的 PPO epoch/mini-batch,也可能来自 engine 层的 micro-batch、dynamic batch size、sequence length、并行通信或 optimizer step。

update-bound 的 actor 更新路径
源码上,_update_actor()会把ppo_mini_batch_size乘以rollout.n,设置epochs、mini_batch_size和compute_loss=True,然后调用 actor worker group;返回 metrics 后把actor/mfu改名成perf/mfu/actor(verl/trainer/ppo/ray_trainer.py:1205-1245)。worker 侧train_mini_batch()会按 DP rank 切 mini-batch,遍历 PPO epochs,逐个 mini-batch 调train_batch();train_batch()再把use_dynamic_bsz、max_token_len_per_gpu、micro_batch_size_per_gpu等执行参数注入 engine,并用engine.train_batch()完成训练(verl/workers/engine_workers.py:250-315、329-382、646-651)。
所以 update-bound 的证据链应该包含两类指标:第一是 timing_s/update_actor和 timing_per_token_ms/update_actor;第二是 perf/mfu/actor、显存指标、mini-batch/micro-batch 配置和 token 长度。如果 update_actor高但 MFU 也高,系统可能已经主要受计算量约束;如果 update_actor高但 MFU 低,就要怀疑 micro-batch 太碎、dynamic batch 装箱、并行通信、offload、数据搬运或 pipeline bubble。
old_log_prob和 update_weights会伪装成两侧瓶颈第 26 篇标题说 rollout generation vs actor update,但实际排查时还有两个阶段经常改变结论:old_log_prob和 update_weights。前者会重新跑 actor inference,后者会把训练后的权重同步回 rollout。它们都不属于“新样本生成”或“optimizer 更新”本身,但都会拉长 step time。
下面这张图把这两个伪装点放在 gen/update 之间。读图时注意:old_log_prob更像训练侧 inference 成本,update_weights更像 train/serve 边界成本;如果忽略它们,就会把瓶颈错误归到 rollout 或 actor update。

old_log_prob 和 update_weights 改变瓶颈归因
_compute_old_log_prob()会把 batch 转 TensorDict、转 no-padding,调用 actor_rollout_wg.compute_log_prob(),再把 no-padding 结果还原成 padding,并返回 old_log_prob_mfu。主循环里它被记录为 timing_s/old_log_prob和 perf/mfu/actor_infer(verl/trainer/ppo/ray_trainer.py:1168-1203、1450-1465)。这说明 old logprob 慢时,不能简单说 rollout 慢;它使用的是 actor inference 路径。
update_weights的含义也不同。配置里 checkpoint engine 默认 backend 是 naive,同时还有 update_weights_bucket_megabytes等同步参数;非 naive 路径会显式处理 abort、release KV cache、build process group、trainer/rollout 双侧 update、resume KV cache 和 resume generation(verl/trainer/config/rollout/rollout.yaml:263-284,verl/checkpoint_engine/base.py:448-492)。如果这个阶段高,下一步应该读第 24 篇的 checkpoint engine 路线,而不是继续调 rollout sampling 或 actor micro-batch。
把上面的证据合在一起,可以得到一个保守但可执行的判定表。它不是自动诊断器,而是帮助读者把观测指标转成下一步源码路径。
观测模式 | 更可能的瓶颈 | 下一步看什么 |
|---|---|---|
timing_s/gen占比高,agent loop slowest 也高 | rollout-bound | response length、tool calls、compute score、preemption、rollout backend profile |
timing_s/update_actor占比高,perf/mfu/actor高 | compute-bound update | 训练 engine、并行维度、PPO epochs、token 数 |
timing_s/update_actor高但 MFU 低 | execution-bound update | micro-batch、dynamic batch、通信、offload、数据搬运 |
timing_s/old_log_prob高 | actor inference 重算成本 | logprob micro-batch、actor inference MFU、bypass/rollout correction 分支 |
timing_s/update_weights高 | weight-sync-bound | checkpoint engine backend、bucket、KV cache、abort/resume |
perf/throughput低但各阶段比例稳定 | workload shape 变化 | total tokens、response length、rollout.n、batch balance |
这张表的核心是把“慢在哪里”变成“下一步读哪里”。如果下一步要改配置,至少应该先明确改的是 rollout 并发、response 长度、actor mini/micro-batch、checkpoint backend,还是数据路径。否则,吞吐调优很容易变成互相抵消的参数试错。
第 26 篇回答的不是一个固定答案,而是一种判断方式:在同步 RLHF 训练里,rollout generation、actor update、old logprob 和 weight sync 都会进入同一个 step time;端到端 throughput 只是把这段时间除以 token 和 GPU 数。真正的瓶颈,是当前配置和 workload 下让闭环等待最久的窗口。
放回系列地图,第 25 篇建立 profiling 账本,第 26 篇把账本转成瓶颈诊断。接下来第 27 篇会继续拆一个更隐蔽的问题:就算 compute 侧看起来合理,DataProto、Ray object、controller 汇总和序列化也可能成为 data movement 的隐藏大头。
verl/trainer/ppo/metric_utils.py:271-346:timing_s/*、per-token timing 和 perf/throughput的计算方式。verl/trainer/ppo/ray_trainer.py:1373-1642:同步 PPO 主循环中 gen、old_log_prob、update_actor、update_weights的顺序和指标汇总。verl/trainer/ppo/ray_trainer.py:1168-1203、1450-1465:old logprob 如何走 actor inference 路径,并记录 perf/mfu/actor_infer。verl/trainer/ppo/ray_trainer.py:1205-1245:actor update 如何注入 PPO mini-batch、epochs、shuffle,并产出 perf/mfu/actor。verl/workers/engine_workers.py:250-315、329-382、646-651:actor worker 如何进入 train_mini_batch()、train_batch()和 engine 训练路径。verl/workers/engine_workers.py:177-235:训练 worker 如何聚合 loss、显存和 MFU metrics。verl/experimental/agent_loop/agent_loop.py:1069-1126:agent loop manager 如何聚合 generation/tool/reward timing 和 slowest sample。verl/experimental/agent_loop/single_turn_agent_loop.py:57-70、verl/experimental/agent_loop/tool_agent_loop.py:216-285:单轮和工具 agent loop 如何记录 generation 与 tool call 时间。verl/checkpoint_engine/base.py:410-492:rollout sleep、非 naive weight sync、KV cache 释放和 resume generation 的同步生命周期。verl/trainer/config/rollout/rollout.yaml:25-31、69-79、263-284:response length、rollout batching 和 checkpoint engine bucket 配置入口。verl/trainer/config/actor/actor.yaml:10-33、114-118:rollout.n、PPO mini-batch、dynamic batch 和 PPO epochs 的配置入口。