Prhub

#7261 [megatron] fix: pad multidimensional THD tensors along the sequence dimension

原始 PR 作者 yyDing1 合并时间 2026-08-04 23:19 文件变更 2 提交数 4 评论 1 代码增减 +47 / -4

执行摘要

修复 THD 多维张量在 zigzag CP 下的序列维补零缺失

PR body 明确描述了崩溃现象:RuntimeError: The expanded size of the tensor (1) must match the existing size (0) at non-singleton dimension 0. Target sizes: [1, 48, 8]. Tensor sizes: [0, 48, 8]。根因是原补零逻辑按一维输入设计(d.shape = [seq_len] 时 d.numel() == d.shape[0]),而同一函数也被 Router Replay 等多维 per-token 元数据使用(d.shape = [seq_len, num_layers, topk]),导致 CP 对齐与 chunk 切分沿 dim 0 进行时补零被跳过,最终 CP rank 读取空序列切片触发崩溃。修复目标是让多维张量也获得形状保持的序列维补零,同时不改变一维输入的既有契约。

值得精读。这是一个小改动揭示典型一维假设问题的好案例:8 行修复解决了多维张量在 CP 下的系统性崩溃,且通过差分测试(2304 组对比)严格保证一维行为不变。值得关注的设计决策:reviewer 对作者分支方案的纠偏(更简单的统一逻辑通常更正确)、以及“用 shape[0] 而不是 numel 做序列长度判断”的普适性原则,对后续接触 THD/CP 预处理的工程师有直接参考价值。

讨论亮点

唯一的 review 评论发生在 verl/models/mcore/util.py:wuxibin89 直接否定了作者在 PR body 中描述的分支方案,指出 pad_target 不正确并给出修正代码。作者接受修改,最终合入代码与 PR body 描述不一致,以合入代码为准。这一纠偏说明:对于本场景,统一将补零目标对齐到 align_size 比按维度区分目标更简单且正确,作者草案中的 seqlen_padded_i 分支属于过度设计。

实现拆解

  1. 根因定位:在 verl/models/mcore/util.py 的 preprocess_thd_engine 的 zigzag CP 分支(cp_size > 1)中,补零判断 if d.numel() < align_size 隐含一维假设。对形状为 [1, 48, 8] 的张量,d.numel() = 384align_size = 4,条件恒为假,补零被跳过;而后续 remain 段切分按 dim 0 进行,remain_end = min(remain_end, d.shape[0]) 会读到长度为 0 的切片并赋给形状 [1, 48, 8] 的目标,触发 RuntimeError。
  2. 修复核心:将判定条件改为 d.shape[0] < align_size,补零张量形状改为 (align_size - d.shape[0], *d.shape[1:]),即沿序列维度补零并保留全部 trailing 维度;一维时 d.shape[0] == d.numel(),判定与补零结果数学等价,行为完全保持。warning 日志同步从 numel 改为 shape[0]。
  3. review 演进:作者最初方案(PR body 所述)按维度区分补零目标 pad_target = align_size if d.dim() == 1 else seqlen_padded_i,reviewer wuxibin89 指出该逻辑不正确并直接给出统一按 align_size 补零的写法,最终合入版本采用 reviewer 方案,比草案更简洁且语义更一致。
  4. 测试配套:tests/utils/test_megatron_bshd_preprocess.py 新增参数化测试覆盖多维 Router Replay 数据在 cp_size=2 下两个 rank 的补零与切分结果,以及一维 need_roll=True 时 zigzag label 滚动的行为保持。PR body 报告了 2304 组差分对比(序列长度 1-64、TP 1/2/4、CP 2/4、全部 CP rank、need_roll 开关),一维行为零差异;全量相关测试 48 个通过。
文件 模块 状态 重要度
verl/models/mcore/util.py CP 预处理 modified 6.05
tests/utils/test_megatron_bshd_preprocess.py CP 预处理 modified 5.77

关键符号

preprocess_thd_engine test_preprocess_thd_engine_pads_multidimensional_router_data test_preprocess_thd_engine_preserves_1d_zigzag_roll_alignment

关键源码片段

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

修复核心所在:将 CP 对齐补零从按标量总数(numel)判断改为按序列维度(shape[0])判断,并让补零张量保留 trailing 维度,直接修复多维 per-token 元数据在 zigzag CP 下的 shape mismatch 崩溃。

# verl/models/mcore/util.py 中 preprocess_thd_engine 的 zigzag CP 分支(cp_size > 1)
# 对当前 batch 内第 i 条序列做 CP 对齐与 chunk 切分
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
# split to 2 chunks
d = input_ids[i]# 修复前:用 d.numel() < align_size 判断,对多维张量 [1, 48, 8] 来说
# numel = 384 >= 4 恒成立,补零被跳过,后续 remain 段读到空切片 [0, 48, 8]
# 修复后:按序列维度(dim 0)判断与补零,保留 trailing 维度(num_layers、topk)
if d.shape[0] < align_size:
    pad_shape = (align_size - d.shape[0], *d.shape[1:])
    pad = torch.zeros(pad_shape, 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={d.shape[0]}, "
        f"align_size={align_size}"
    )# zigzag 切分:前半段由 cp_rank 决定,后半段反向读取(remain 段)
input_ids_rmpad[start_idx : start_idx + half_seqlen] = d[
    half_seqlen * cp_rank : half_seqlen * (cp_rank + 1)
]# Build position_ids for the first chunk
position_ids_rmpad[start_idx : start_idx + half_seqlen] = torch.arange(
    half_seqlen * cp_rank, half_seqlen * (cp_rank + 1), dtype=torch.long, device=input_ids.device
)remain_start = seqlen_padded_i - half_seqlen * (cp_rank + 1)
remain_end = seqlen_padded_i - half_seqlen * cp_rank
# 用 d.shape[0] 而非 numel 截断,配合修复后的补零保证多维张量也能安全切片
remain_end = min(remain_end, d.shape[0])
remain_len = remain_end - remain_start
if remain_len > 0:
    input_ids_rmpad[start_idx + half_seqlen : start_idx + half_seqlen + remain_len] = d[
        remain_start:remain_end
    ]
    # Build position_ids for the remaining chunk: use remain_start as base,
    # clamped to original seqlen to avoid exceeding seqlen-1 for padded positions
    pos_end = min(remain_end, seqlen_orig_i)
    valid_pos_len = pos_end - remain_start
    if valid_pos_len > 0:
        position_ids_rmpad[start_idx + half_seqlen : start_idx + half_seqlen + valid_pos_len] = (
            torch.arange(remain_start, pos_end, dtype=torch.long, device=input_ids.device)
        )
tests/utils/test_megatron_bshd_preprocess.py test-coverage

新增两类回归测试:多维 Router Replay 数据按序列维补零后各 CP rank 均拿到形状正确的 chunk;一维 label 在 need_roll=True 下 zigzag 滚动行为保持,防止修复破坏旧契约。

# tests/utils/test_megatron_bshd_preprocess.py 新增回归测试@pytest.mark.parametrize("cp_rank", [0, 1])
def test_preprocess_thd_engine_pads_multidimensional_router_data(monkeypatch, cp_rank):
    mcore_util = _load_mcore_util_with_stubbed_megatron(
        monkeypatch,
        tp_size=1,
        cp_size=2,
        cp_rank=cp_rank,
    )
    # Router Replay 的 per-token 元数据:1 个 token、48 层、top-8 专家
    route = torch.arange(48 * 8, dtype=torch.long).reshape(1, 48, 8)
    routed_experts = _nested_tensor([route])
​
    local_routes, packed_seq_params, _ = mcore_util.preprocess_thd_engine(routed_experts)
​
    # 序列长度 1 补零到 4(align_size),cp_size=2 时每个 rank 分到 2 行
    expected = torch.zeros((2, 48, 8), dtype=torch.long)
    if cp_rank == 0:
        expected[0] = route[0]
    assert local_routes.shape == (1, 2, 48, 8)
    assert packed_seq_params.cu_seqlens_q_padded.tolist() == [0, 4]
    torch.testing.assert_close(local_routes[0], expected)
​
​
@pytest.mark.parametrize(
    ("cp_rank", "expected"),
    [
        (0, [2, 3, 1, 1]),
        (1, [4, 5, 6, 7]),
    ],
)
def test_preprocess_thd_engine_preserves_1d_zigzag_roll_alignment(monkeypatch, cp_rank, expected):
    # 回归保护:一维 label 在 need_roll=True 时的 zigzag 滚动边界行为必须保持
    mcore_util = _load_mcore_util_with_stubbed_megatron(
        monkeypatch,
        tp_size=1,
        cp_size=2,
        cp_rank=cp_rank,
    )
    labels = _nested_tensor([torch.arange(1, 8, dtype=torch.long)])
​
    local_labels, packed_seq_params, _ = mcore_util.preprocess_thd_engine(labels, need_roll=True)
​
    assert packed_seq_params.cu_seqlens_q_padded.tolist() == [0, 8]
    torch.testing.assert_close(local_labels[0], torch.tensor(expected, dtype=torch.long))

评论区精华

pad_target 分支方案被 reviewer 纠正 设计

作者最初方案(PR body 所述)按张量维度区分补零目标:一维用 align_size,多维用 seqlen_padded_i。wuxibin89 评论指出 `pad_target` 不正确,并直接给出修正代码:`if d.shape[0] < align_size: pad_shape = (align_size - d.shape[0], *d.shape[1:])`。

结论:合入版本采用 reviewer 建议,统一按序列维度与 align_size 对齐补零;PR body 中描述的分支逻辑与实际合入代码不一致,以合入代码为准。 · 已解决

风险与影响

  1. 核心路径变更:preprocess_thd_engine 是 Megatron 后端 THD 输入预处理的核心函数,zigzag CP 布局下每个序列都会经过该分支,改动影响面广。
  2. 数据契约变更:多维张量(Router Replay route、teacher top-k 等)从“不补零”变为“沿序列维补零”,依赖方需确认 shape 语义;但对一维输入数学等价,d.shape[0] == d.numel(),且测试覆盖一维差分对比 2304 组零差异。
  3. 日志语义变化:warning 日志 original_size 从 numel 变为 shape[0],一维场景数值不变,多维场景数值变小,仅影响日志可读性。
  4. 超短序列边界:修复只保证 d.shape[0] >= align_size,对 align_size <= shape[0] < seqlen_padded_i 的中间区间仍依赖 remain_end = min(remain_end, d.shape[0]) 截断保护,行为与一维路径一致。

影响范围集中在使用 Megatron 后端 + CP(尤其 zigzag 布局)+ 多维 per-token 元数据(Router Replay、蒸馏 teacher top-k)的训练作业:此前这类组合在短序列下必然崩溃,修复后各 CP rank 能获得形状正确的 chunk。输入 IDs、labels、loss masks、replay masks 等一维数据路径行为不变;cp_size <= 1、contiguous CP 布局不受影响。无公共 API 或配置变更,对用户是透明的 bugfix。对团队的启示是:同一预处理函数被一维 token 数据和多维元数据共用,任何长度/对齐假设都需要按维度显式区分。

核心路径变更 数据契约变更

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论