Prhub

#7702 [megatron] fix: pad VLM THD input to TP/CP alignment before SP scatter

原始 PR 作者 HollowMan6 合并时间 2026-09-11 10:17 文件变更 2 提交数 1 评论 1 代码增减 +19 / -13

执行摘要

对齐 VLM THD 序列长度,修复 Megatron SP 崩溃

PR body 指出这是一个真实的训练崩溃:当 use_fused_kernels=True、use_dynamic_bsz=True、sequence_parallel=True 同时开启时,视觉分支用 to_padded_tensor 把嵌套 input_ids 转成 BSHD「without TP/CP alignment padding」,Bridge 随后把 combined_embeddings 展平成 [S, B, H] 调用 scatter_to_sequence_parallel_region,而该算子断言 S % tp_size == 0;在 4026 tokens、TP=2 的场景下断言直接触发、任务崩溃。作者进一步定位到 B=1(micro-batch size 1)时 Bridge 的 legacy_packed_bshd 判定(input_ids.size(0) > 1)为假,其内部本可做逐序列 TP 对齐的 preprocess_packed_seqs 被跳过,未对齐的 combined_embeddings 一路走到 SP scatter。非 fused 路径经 build_vlm_attn_mask_thd 使用裸 to_padded_tensor,存在同一 bug,因此需要一处修复覆盖两条路径。

建议精读,尤其是正在维护 Megatron VLM / 序列并行 / 上下文并行前向路径的工程师。值得关注的三个设计点:一是「SP scatter 要求 S 能被 tp_size 整除」这一硬约束通过 padding 在模型入口处满足,而不是在 scatter 处打补丁;二是 CP>1 时对齐粒度取 tp_size * cp_size * 2 的推导(zigzag 切分保证单 rank 局部长度仍可被 tp_size 整除);三是把对齐逻辑收敛到 build_vlm_attn_mask_thd、让 fused 路径复用,避免两套实现继续分叉。同时应把 Copilot 的测试建议当作待办跟进:补一个覆盖 B=1、4026 这类奇数长度的 CPU 单测,断言输出 S 可被 tp_size(CP>1 时为 tp_size * cp_size * 2)整除,并在 CP 分支校验每个 rank 拿到完整切片。

讨论亮点

本轮 review 讨论量很小:1 条 Copilot review comment + 1 次人工 approve。最有价值的两点是:

  1. 测试覆盖缺口(Copilot,verl/models/mcore/util.py:862):认可「对齐 padding 修复了真实的崩溃模式」,但强调「there is no unit test asserting the padded max_seqlen is divisible by tp_size (or tp_size * cp_size * 2 when CP>1)」,并建议构造 B=1 + 4026 这类奇数 token 数的用例。该意见未被解决,PR 在 wuxibin89 approve 后直接合入。

  2. CP padding 下界与告警文案:Copilot 在 review body 的 suppressed comment 中指出 verl/models/mcore/util.py:488 处的 warning「claims padding is only for align_size, but the new logic pads to...」,即日志文案与实际逻辑脱节,属可读性/可观测性瑕疵,未阻塞合入。

人工 reviewer wuxibin89 给出 APPROVED 且无文字评论,说明维护者对「把对齐逻辑收敛到共享 helper」这一方向没有异议。

实现拆解

  1. 根因定位(PR body + 讨论):崩溃发生在 SP scatter 的 S % tp_size == 0 断言上,触发条件是 THD 打包序列的最大长度未按 TP/CP 对齐,且 B=1 时 Megatron Bridge 跳过内部逐序列对齐逻辑。修复思路不是绕开 scatter,而是让 padding 长度在进入模型前就满足对齐口径。

  2. 对齐逻辑下沉到 build_vlm_attn_mask_thd(verl/models/mcore/util.py):新增 tp_size = mpu.get_tensor_model_parallel_world_size() / cp_size = mpu.get_context_parallel_world_size() 读取,按 align_size = tp_size * cp_size * 2 if cp_size > 1 else tp_size 计算对齐粒度,对 seqlens_in_batch.max() 向上取整后显式传给 to_padded_tensor(pad_token_id, output_size=(batch_size, max_seqlen))。该口径与既有 build_vlm_attn_mask_bshd 完全一致(CP>1 时因 zigzag 切分需要先满足 2*cp_size,再满足 tp_size)。影响:attention_mask 随 padded 形状一起变大,多出来的列全为 False,不参与注意力。

  3. fused 路径复用共享 helper(verl/models/mcore/model_forward_fused.py):fused_forward_model_engine_inner 的 vision_model 分支删除内联的 input_ids.to_padded_tensor(pad_token_id) 与 torch.arange(max_seq_len) < seqlens_in_batch 手工掩码构造,改为一处 input_ids_rmpad, attention_mask = build_vlm_attn_mask_thd(input_ids, pad_token_id),并新增对应 import。收益有两点:fused 与非 fused 路径共享同一份对齐修复;input_ids_rmpad 与 attention_mask 由同一个 helper 产出,形状天然一致,避免两处独立构造导致的错位。

  4. CP 分支 padding 下界修正(preprocess_thd_engine,verl/models/mcore/util.py):CP>1 的切分分支把 if d.shape[0] < align_size 改为 pad_target = max(align_size, seqlen_padded_i) 后再比较与填充。原因是只补到 align_size 可能仍短于该序列的 padded 长度 seqlen_padded_i,会让 remain_end = min(remain_end, d.shape[0]) 提前截断,导致部分 CP rank 拿不到完整切片。注意 logger.warning_once 的文案仍只打印 align_size,与新的 pad_target 语义不完全对应(Copilot 的 suppressed comment 也指出了这一点)。

  5. 测试、配置与部署配套:缺失。本 PR 未新增或修改任何测试文件,也没有配置键、CLI 参数或 schema 变更。作者给出的验证是端到端证据:Qwen3.6-35B-A3B VLM Geo3K Megatron LoRA、4 节点 32× H20、merge=False,修复前在 TP=2 + SP 下崩溃,修复后 step1 pearson 0.9995、entropy 0.22、reward 0.65、390/390 adapter weights 流式传输成功。

文件 模块 状态 重要度
verl/models/mcore/util.py 模型前向 modified 6.53
verl/models/mcore/model_forward_fused.py 模型前向 modified 5.69

关键符号

build_vlm_attn_mask_thd preprocess_thd_engine fused_forward_model_engine_inner build_vlm_attn_mask_bshd

关键源码片段

verl/models/mcore/util.py data-contract

核心修复点:`build_vlm_attn_mask_thd` 增加 TP/CP 对齐 padding(与非 fused 的 `build_vlm_attn_mask_bshd` 口径一致),并修正 `preprocess_thd_engine` 中 CP 切分的 padding 下界,是本次崩溃的根因所在。

1)build_vlm_attn_mask_thd:把 TP/CP 对齐补齐到 THD 路径

def build_vlm_attn_mask_thd(input_ids: torch.Tensor, pad_token_id: int = None):
    # input_ids 是 THD 布局的 jagged 嵌套张量,offsets() 的差分即每条序列的真实长度
    seqlens_in_batch = input_ids.offsets().diff()
​
    # 对齐到 TP/CP:combined_embeddings 在 SP scatter 前会被展平成 [S, B, H],
    # 只有 S % tp_size == 0 才不会触发 scatter_to_sequence_parallel_region 的断言;
    # 对齐口径与 build_vlm_attn_mask_bshd 保持一致。
    tp_size = mpu.get_tensor_model_parallel_world_size()
    cp_size = mpu.get_context_parallel_world_size()
    # CP 走 zigzag 切分时,每条序列先按 2 * cp_size 对齐,切分后单个 rank 的局部长度
    # 仍需被 tp_size 整除,因此总长度必须是 tp_size * cp_size * 2 的倍数。
    align_size = tp_size * cp_size * 2 if cp_size > 1 else tp_size
    max_seqlen = int(seqlens_in_batch.max().item())
    if align_size > 1:
        max_seqlen += (align_size - max_seqlen % align_size) % align_size
​
    batch_size = input_ids.shape[0]
    # 显式传入 output_size,避免沿用未对齐的原始最大长度(旧实现就是这么崩的)
    input_ids_with_pad = input_ids.to_padded_tensor(pad_token_id, output_size=(batch_size, max_seqlen))
    attention_mask = torch.zeros_like(input_ids_with_pad, dtype=torch.bool)
    for i, seqlen in enumerate(seqlens_in_batch):
        # 只有真实 token 为 True,对齐补出来的尾部位置不参与注意力
        attention_mask[i, :seqlen] = True
​
    return input_ids_with_pad, attention_mask

2)preprocess_thd_engine 的 CP 分支:填充下界改为 max(align_size, seqlen_padded_i)

            # CP 分支:每条序列按 cp_size 切成 2 段,每个 rank 取一整段
            seqlen_padded_i = seqlens_in_batch_padded_cpu[i]
            seqlen_orig_i = seqlens_in_batch_cpu[i]
            seqlen = seqlen_padded_i // cp_size
            half_seqlen = seqlen // 2
            start_idx = cu_seqlens_padded_cpu[i] // cp_size
            d = input_ids[i]
​
            # 只补到 align_size 可能仍短于本序列的 padded 长度 seqlen_padded_i,
            # 会让下方 remain 区间被 d.shape[0] 截断、部分 CP rank 拿不到完整片段,
            # 因此取两者的最大值作为填充目标。
            pad_target = max(align_size, seqlen_padded_i)
            if d.shape[0] < pad_target:
                original_size = d.shape[0]
                pad = torch.zeros((pad_target - d.shape[0], *d.shape[1:]), dtype=d.dtype, device=d.device)
                d = torch.cat([d, pad], dim=0)
                logger.warning_once(
                    f"Padding tensor for context parallel alignment, original_size={original_size}, "
                    f"align_size={align_size}" # 文案仍只提 align_size,未反映 pad_target
                )
​
            # 第一段切片
            input_ids_rmpad[start_idx : start_idx + half_seqlen] = d[
                half_seqlen * cp_rank : half_seqlen * (cp_rank + 1)
            ]
​
            # 第二段(remain)切片,remain_end 不再被过短的 d 截断
            remain_start = seqlen_padded_i - half_seqlen * (cp_rank + 1)
            remain_end = min(seqlen_padded_i - half_seqlen * cp_rank, d.shape[0])
            remain_len = remain_end - remain_start
verl/models/mcore/model_forward_fused.py core-logic

fused 前向入口 `fused_forward_model_engine_inner` 的 vision 分支改用共享 helper,删除内联的未对齐 `to_padded_tensor` 和手工 mask,是让 fused 路径真正吃到对齐修复的关键一步。

fused_forward_model_engine_inner 的 vision 分支:统一走对齐 helper

        attention_mask = None
        if vision_model:
            # 视觉分支统一走 build_vlm_attn_mask_thd:
            # - 旧实现在这里直接 input_ids.to_padded_tensor(pad_token_id),再用
            # arange(max_seq_len) < seqlens_in_batch 手工造 mask,最大长度未对齐;
            # B=1 时 Megatron Bridge 的 legacy_packed_bshd 判定 input_ids.size(0) > 1
            # 为假,内部逐序列 TP 对齐的 preprocess_packed_seqs 被跳过,
            # combined_embeddings 会以未对齐的 S 走到 SP scatter 触发断言崩溃;
            # - 现在 input_ids_rmpad 与 attention_mask 由同一个 helper 产出,形状天然一致,
            # 并与非 fused 路径共享同一份 TP/CP 对齐口径。
            input_ids_rmpad, attention_mask = build_vlm_attn_mask_thd(input_ids, pad_token_id)
​
        labels_rmpad, _, _ = preprocess_thd_engine(
            labels,
            pre_process=True,
            need_roll=True,
            use_fp8_padding=use_fp8_padding,
            min_local_rows=min_local_rows,
            pad_to_length_bucket=pad_to_length_bucket,
            cp_layout=cp_layout,
            local_cp_size=local_cp_size,
        )
        labels_rmpad = labels_rmpad.contiguous()
        forward_kwargs = dict(
            input_ids=input_ids_rmpad,
            attention_mask=attention_mask,
            position_ids=None,
            packed_seq_params=packed_seq_params,
            labels=labels_rmpad,
            **model_kwargs,
        )

评论区精华

缺少 TP/CP 对齐的单测覆盖 测试

Copilot 在 `verl/models/mcore/util.py:862` 评论:修复了真实的崩溃模式,但没有单测断言 padded `max_seqlen` 能被 `tp_size`(CP>1 时为 `tp_size * cp_size * 2`)整除;鉴于该函数现在同时被 fused 与非 fused 前向使用,建议补一个聚焦用例(例如 B=1、4026 这类奇数 token 数、TP=2)。

结论:未在该 PR 中补充测试,作者以 Qwen3.6-35B-A3B VLM Geo3K 的 4 节点 E2E 结果(step1 pearson 0.9995)作为验证证据;wuxibin89 approve 后 PR 合入,测试作为遗留待办。 · 未解决(已合入)

CP 分支 padding 下界从 align_size 改为 max(align_size, seqlen_padded_i) 正确性

Copilot 的 file summary 确认该调整是为了避免 CP 切片越过可用序列长度("pad up to the full per-sequence padded length to avoid CP slicing beyond the available sequence length");同时其 suppressed comment 指出 `util.py:488` 附近的 warning 文案仍声称 padding 只为 `align_size` 而做,与新的 `pad_target` 逻辑不符。

结论:逻辑本身被接受并合入,仅日志文案的准确性问题被记为小瑕疵,未阻塞合并。 · 已接受(文案待修)

fused 路径改用共享 helper 的设计取舍 设计

Copilot 的 review body 将「Route fused VLM forward attention-mask construction through `build_vlm_attn_mask_thd` instead of an inline unaligned `to_padded_tensor` path」列为改动要点之一;这把两条前向路径的对齐口径收敛到单一实现,代价是扩大了该 helper 的影响面。

结论:维护者 wuxibin89 直接 APPROVED 且未提出异议,方向被认可。 · 已解决

风险与影响

  1. 缺少回归测试(最高优先级):build_vlm_attn_mask_thd 现在同时服务 fused 与非 fused 两条 VLM 前向路径,却没有单测锁定 max_seqlen % tp_size == 0(CP>1 时为 tp_size * cp_size * 2)这一不变量。Copilot 已经点名这个风险,PR 仍合入,后续任何改动都可能静默破坏对齐,且只有在特定 B=1 + 奇数 token 数场景下才暴露为崩溃。

  2. 共享 helper 扩大了影响面:model_forward_fused.py 从内联构造改为调用 build_vlm_attn_mask_thd 后,fused 路径的 mask 构造方式发生变化——旧的 torch.arange(...) < seqlens_in_batch.unsqueeze(1) 是设备端向量化(且显式 .to(input_ids.device)),新实现是 torch.zeros_like(..., dtype=torch.bool) 加 CPU 侧 Python 循环逐行填充。功能等价且开销可忽略,但属于同一 PR 里的隐式行为变更,若 mask dtype/设备语义有细微差异,影响的是整个 VLM fused 前向。

  3. padded 长度变化带来的下游形状约束:input_ids_with_pad 的 S 变大后,需保证 postprocess_thd_engine 侧按 seqlens_in_batch 切片/还原的逻辑仍能对齐;build_vlm_attn_mask_bshd 中专门有 forced_max_seqlen 参数来保证 logits.shape[:2] == label.shape[:2],说明这类 padded 维度在 BSHD 路径上确实需要与 label 严格对齐,而 THD 路径本次没有对应的显式约束或断言。

  4. CP 分支填充语义变化:pad_target = max(align_size, seqlen_padded_i) 会让更多序列被补零到 seqlen_padded_i,补出来的内容本身是 padding(position_ids 已按 seqlen_orig_i 截断),因此不改变有效 token,但确实改变了进入 CP 切分的张量长度,属于该分支的隐性契约调整,同样无单测覆盖。

  5. 性能影响可忽略:每个 batch 最多多算 align_size - 1(CP>1 时为 tp_size * cp_size * 2 - 1)个 token 的 embedding 与注意力掩码开销,相对 4 节点规模的训练可以忽略。

用户侧:修复合乎条件的 VLM Megatron 训练任务从「必崩」变为「可跑」,影响面是一组明确受限的配置组合——VLM(vision_model=True)+ use_fused_kernels=True + use_dynamic_bsz=True + sequence_parallel=True(或 CP>1),典型场景是 Qwen3.6-35B-A3B VLM Geo3K Megatron LoRA 这类多模态 RL 训练;不满足该组合的用户完全不受影响,且无 API/配置破坏性变更。系统侧:对齐口径在 THD 与 BSHD 之间完成统一,减少了同一不变量在两处实现漂移的可能,但也把两条前向路径耦合到同一个 helper 上,出问题时爆炸半径同时覆盖 fused 与非 fused。团队侧:这是一次「先 E2E 验证、后补测试」的合入,维护者需要在后续补上针对 build_vlm_attn_mask_thd 的 CPU 单测(仓库已有大量 tests/utils/*_on_cpu.py 风格用例),否则该修复的长期稳定性依赖人工回归。

缺少测试覆盖 核心前向路径变更 多路径共享 helper 影响面扩大 padded 维度与下游形状契约未加断言

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论