
mini-batch 决定算法用哪些样本更新,micro-batch 决定 GPU 一次承受多少工作,dynamic batch size 决定这些工作如何按 token 重新装箱。 第22篇拆清mini-batch、micro-batch与dynamic batch size三层语义:前者决定PPO/GRPO算法窗口,后者决定GPU执行切片,dynamic则按token预算重 新装箱。文章从verl源码追到loss归一化,解释batch配置为何直接影响显存、吞吐和训练语义。
第 21 篇解释了 EngineRegistry和 BaseEngine如何把不同训练后端收进同一组 worker 合同。接下来最容易混淆的不是后端名字,而是 batch 名字:ppo_mini_batch_size、ppo_micro_batch_size_per_gpu、use_dynamic_bsz、ppo_max_token_len_per_gpu、global_batch_size同时出现时,究竟哪个在定义算法,哪个在定义显存和吞吐?
本文的核心判断是:verl 里至少有三层 batch。第一层是算法 mini-batch:controller 决定一次 PPO/GRPO update 用多少样本、跑几轮 epoch、是否 shuffle。第二层是 engine micro-batch:worker 和 engine 把每个 mini-batch 切成能放进 GPU、能喂给 FSDP 或 Megatron schedule 的执行切片。第三层是 dynamic batch size:当样本长度差异很大时,不再按固定样本数切 micro-batch,而是按 token 上限和近似计算量重新装箱。把这三层分开,才能解释 loss 归一化、显存峰值、pipeline bubble 和训练语义之间的关系。
先看第 22 篇的整体地图。读图时注意:balance_batch发生在 controller 分发前,mini-batch 发生在 worker 的算法迭代里,micro-batch 和 dynamic batch 发生在 engine 执行里。

batch 名词在一次 update 里的位置
源码路径对应这条链路:RayPPOTrainer._update_actor()和 _update_critic()写入 mini_batch_size、epochs、seed、dataloader_kwargs和 global_batch_size;TrainingWorker.train_mini_batch()再按 data parallel size 计算每个 rank 的 mini-batch;TrainingWorker.train_batch()注入 use_dynamic_bsz、max_token_len_per_gpu、micro_batch_size_per_gpu;最后各 engine 在 forward_backward_batch()里调用 prepare_micro_batches()(verl/trainer/ppo/ray_trainer.py:1205-1265,verl/workers/engine_workers.py:238-382,verl/workers/engine/utils.py:58-96)。
mini-batch 首先是算法概念。它回答的是:这一轮 PPO/GRPO update 中,多少条样本组成一个更新窗口,要重复训练几个 epoch,是否打乱顺序。它不直接回答 GPU 一次能放多少 token。
下面这张图把 controller 到 worker 的 mini-batch 路径画出来。看图时注意:rollout.n会放大 actor/critic 的 PPO mini-batch,因为同一个 prompt 可能对应多条 response。

PPO mini-batch 如何进入 TrainingWorker
actor 更新里,_update_actor()先把 DataProto 转成 TensorDict,再把 actor.ppo_mini_batch_size * rollout.n写成 mini_batch_size和 global_batch_size,同时写入 ppo_epochs、data_loader_seed、shuffle;critic 更新走类似路径(verl/trainer/ppo/ray_trainer.py:1211-1238,1247-1265)。配置层也对应这个语义:actor 的 ppo_mini_batch_size注释是“Split each sample into sub-batches of this size for PPO”,critic 也有自己的 ppo_mini_batch_size和 ppo_epochs(verl/trainer/config/actor/actor.yaml:17-33,verl/trainer/config/critic/critic.yaml:36-52)。
进入 worker 后,train_mini_batch()用当前 engine 的 DP size 把全局 mini_batch_size换算成 mini_batch_size_per_gpu,并用 seed + data_parallel_rank构造 dataloader;tu.make_iterator()要求 TensorDict batch size 能被 mini-batch size 整除,并按 epoch 反复 yield mini-batch(verl/workers/engine_workers.py:248-275,verl/utils/tensordict_utils.py:559-612)。所以 mini-batch 的作用是让算法按样本窗口迭代,DP rank 只是把这个窗口分到不同设备上。
一个 mini-batch 进入 engine 后,往往仍然太大。micro-batch 才是模型 forward/backward 实际承受的执行单位。它回答的是:每张 GPU 一次处理多少样本,或者在 dynamic batch size 下处理多少 token。
下面这张图展示 mini-batch 到 micro-batch 的边界。看图时注意:BaseEngine.train_batch()看起来只有一次调用,但 forward_backward_batch()内部可能已经切成多个 micro-batch。

mini-batch 如何被切成 engine micro-batch
TrainingWorker.train_batch()会把 use_dynamic_bsz、max_token_len_per_gpu、micro_batch_size_per_gpu、use_remove_padding和 use_fused_kernels写入 TensorDict;推理路径 infer_batch()也会注入对应的 infer micro-batch 参数(verl/workers/engine_workers.py:328-428)。BaseEngine.train_batch()只固定训练顺序:清梯度、调用 forward_backward_batch()、optimizer step;它不规定 micro-batch 怎么切(verl/workers/engine/base.py:112-148)。
真正切 micro-batch 的地方在 prepare_micro_batches()。如果 use_dynamic_bsz=False,它要求数据长度能被 force_group_size * micro_batch_size_per_gpu整除,然后用 chunk_tensordict()按固定样本数切块;这里的 micro_batch_size_per_gpu是本地每 GPU 的执行样本数,不是全局 batch size(verl/workers/engine/utils.py:88-96,verl/utils/tensordict_utils.py:302-322)。
dynamic batch size 的名字容易误导。它不是自动增大训练 batch,也不是改变 PPO mini-batch 的算法窗口;它改变的是 micro-batch 的切法:从“固定样本数”切换到“每个 micro-batch 的 token 工作量不超过上限,并尽量平衡”。
下面这张图把 prepare_micro_batches()的两条分支并排放在一起。看图时注意:dynamic 分支会返回 indices,因为后续还要把输出恢复到原始顺序。

固定 micro-batch 与 dynamic batch size 的分支
当 use_dynamic_bsz=True时,prepare_micro_batches()要求 batch 里有 max_token_len_per_gpu,并把 token 上限换算成 max_token_len_per_gpu * sp_size;随后调用 rearrange_micro_batches()(verl/workers/engine/utils.py:69-87)。rearrange_micro_batches()先从 nested input_ids或 attention_mask估计每条样本的有效长度,要求 max_token_len至少覆盖最长样本;再按总 token 数估算需要多少 micro-batch,并在 DP group 上同步最大 micro-batch 数,必要时按 VPP 分组要求向上取整(verl/utils/seqlen_balancing.py:348-409)。
更关键的是重排逻辑。rearrange_micro_batches()用 calculate_workload()估算每条样本的注意力计算压力,再用分区算法把样本索引分给不同 micro-batch;如果开启 dynamic balance,还会把较小 micro-batch 放在两端,以减少 pipeline warm-up/cool-down 暴露的 bubble(verl/utils/seqlen_balancing.py:27-47,414-468)。输出恢复则由 restore_dynamic_batch()根据 index map 逆变换完成;engine 的 postprocess_batch_func()在 dynamic 模式下会调用它恢复 model output 顺序(verl/utils/seqlen_balancing.py:528-551,verl/workers/engine/utils.py:99-159)。
这就是 dynamic batch size 的真实含义:它牺牲固定样本数的简单性,换取按 token 和近似 workload 更均衡的执行切片。算法仍然看同一个 mini-batch,只是 engine 内部把样本重新装进更合适的 micro-batch。
第 21 篇说过,BaseEngine固定合同,不固定实现。micro-batch 正是后端差异很集中的地方:FSDP 可以逐个 micro-batch forward/backward;Megatron 需要把 micro-batch 数交给 pipeline schedule,并处理 VPP 整除和 loss 缩放。
下面这张图展示两个后端如何消费同一组 micro-batch。看图时注意:同样来自 prepare_micro_batches(),进入后端后的执行语义已经不同。

FSDP 与 Megatron 如何消费 micro-batch
FSDP 路径里,forward_backward_batch()写入 sp_size、全局 token 数和 DP size,然后调用 prepare_micro_batches();随后循环每个 micro-batch,执行 forward_step(),非 forward-only 时直接 loss.backward(),最后把所有 micro-batch 输出交给 postprocess_batch_func()(verl/workers/engine/fsdp/transformer_impl.py:608-645,1210-1252)。
Megatron 路径也会先计算全局 token 数和 DP size,再调用 prepare_micro_batches();但如果启用 virtual pipeline parallel,它会读取 microbatch_group_size_per_vp_stage,要求 micro-batch 数可被该值整除。随后 Megatron engine 给每个 micro-batch 写入 num_micro_batch,调用 get_forward_backward_func()和 make_batch_generator(),把执行交给 pipeline schedule;postprocess 中还要把 loss 乘回 num_micro_batch,因为 pipeline schedule 内部会做缩放(verl/workers/engine/megatron/transformer_impl.py:599-674,963-1001)。
所以 micro-batch 不是一个纯配置数字。对 FSDP,它更像梯度累积和显存切片;对 Megatron,它还参与 pipeline 调度、VPP 分组和 loss 缩放。第 22 篇不展开每个后端的所有细节,但要把这个边界钉住。
如果 micro-batch 只是性能切片,为什么还要反复讨论它?因为 loss 计算不能被 micro-batch 切分改变语义。一次算法 mini-batch 的 loss 应该按全局样本或全局 token 归一化,而不是按某个本地 micro-batch 的大小随意变化。
engine 因此会在 forward_backward_batch()里计算 loss_mask.sum(),并在 data parallel group 上 all-reduce 得到 batch_num_tokens,再把 dp_size写入 batch;FSDP、Megatron、TorchTitan、Automodel 等后端都有类似动作(verl/workers/engine/fsdp/transformer_impl.py:612-618,verl/workers/engine/megatron/transformer_impl.py:602-608)。ppo_loss()会把 dp_size、batch_num_tokens、global_batch_size写入 config.global_batch_info,后续 policy loss、entropy loss 和 KL loss 都把这组信息传给聚合函数(verl/workers/utils/losses.py:57-144)。
agg_loss()则把归一化规则说得更直接:token-mean需要全局 batch_num_tokens,seq-level 模式需要 global_batch_size,并用 dp_size补偿 data parallel 下的梯度平均;注释也明确指出 FSDP 的 loss 会直接 backward,而 Megatron 的 loss 还要按 micro-batch/pipeline schedule 缩放(verl/trainer/ppo/core_algos.py:1141-1198)。这说明 micro-batch 可以重排、拆分、走不同后端调度,但最终必须回到同一个算法 mini-batch 的 loss 语义。
还有一个相邻但不同的机制:trainer 侧的 balance_batch。它在 rollout 结果进入 update 前,根据全局 sequence length 把样本重排到不同 DP rank,目的是让各 rank 的 token 工作量更接近;它发生在 controller 分发前,而 dynamic batch size 发生在 engine 内部切 micro-batch 时(verl/trainer/ppo/ray_trainer.py:1060-1128,1399-1418)。把这两者混为一谈,会误判性能瓶颈在 controller 还是在 engine。
本文把第 22 篇要解决的 batch 名词收束成一条路径:
rollout batch
-> optional controller-side balance_batch across DP ranks
-> PPO/GRPO mini-batch with epochs and shuffle
-> per-DP-rank mini-batch iterator
-> engine micro-batches
-> fixed sample chunks or dynamic token/workload packing
-> backend-specific forward/backward schedule
-> global loss normalization
放回系列地图,第 22 篇补的是 training engine/distributed core里的执行单位:同一套 RL 目标为什么要同时管理算法 batch、GPU micro-batch 和 token 预算。下一篇会继续沿着 token 维度走下去,解释长上下文训练为什么不能只靠缩小 micro-batch,还需要 Sequence Parallel 把序列维度本身变成分布式对象。
verl/trainer/ppo/ray_trainer.py:1205-1265:actor/critic update 如何写入 mini_batch_size、global_batch_size、epoch、seed 和 shuffle。verl/trainer/ppo/ray_trainer.py:1060-1128、1399-1418:controller 侧 balance_batch如何按 sequence length 重排 DP 分发。verl/trainer/config/actor/actor.yaml:17-33、verl/trainer/config/critic/critic.yaml:36-52、verl/trainer/config/rollout/rollout.yaml:97-109:mini-batch、micro-batch、dynamic batch size 和 logprob batch 的配置入口。verl/workers/config/actor.py:149-240、verl/workers/config/critic.py:72-150:actor/critic 对 micro-batch 与 dynamic batch 的配置校验。verl/workers/engine_workers.py:238-382:TrainingWorker.train_mini_batch()和 train_batch()如何把算法 mini-batch 转成 engine 调用。verl/workers/engine_workers.py:384-428:推理路径如何注入 infer micro-batch 和 dynamic batch 参数。verl/utils/tensordict_utils.py:559-612:make_iterator()如何按 mini-batch size 和 epoch 生成 TensorDict mini-batch。verl/workers/engine/base.py:112-148:BaseEngine.train_batch()和 infer_batch()的统一外壳。verl/workers/engine/utils.py:58-159:prepare_micro_batches()与 postprocess_batch_func()的固定切分、dynamic 重排和输出恢复。verl/utils/seqlen_balancing.py:27-47、348-468、528-551:dynamic batch size 如何估算 workload、重排 micro-batch 并恢复原始顺序。verl/workers/engine/fsdp/transformer_impl.py:608-645、1210-1252:FSDP 如何逐 micro-batch forward/backward。verl/workers/engine/megatron/transformer_impl.py:599-674、963-1001:Megatron 如何把 micro-batch 交给 pipeline schedule,并处理 VPP 与 loss 缩放。verl/workers/utils/losses.py:57-144、verl/trainer/ppo/core_algos.py:1141-1198:全局 token、DP size 和 global batch size 如何进入 loss 归一化。