已开启
[Bug]: MindSpore FSDP shard size=1 时无效 backward prefetch 导致训练轨迹分叉 #363
lzy0920232创建于  9月3日
lzy0920232
9月3日 创建

Checklist

问题描述

在 MindSpore backend 下,模型模块虽然经过 fully_shard 包装,但实际 FSDP shard group size 为 1 时,跨 TransformerLayer 的 backward prefetch 仍调用 unshard(async_op=True)。

该场景不存在参数 AllGather,也没有可重叠的异步通信。MindSpore 参数本地 unshard 分支只进行本地 buffer 建立/复制并返回 allgather_handle=None;目标层真正进入 backward 时又执行一次 unshard,造成参数 buffer 生命周期及 replay/backward 调度交叠。相同 seed、相同配置重复运行会出现训练轨迹概率性分叉。

问题不是 DSA/Indexer 独有:关闭 DSA、只运行 MLA 时同样可以复现。

关联修复 PR:#1326

环境与版本

  • Hyper-Parallel master 基线:eccce0bc227d007c948ae04076d91622002a13c8
  • MindFormers master 基线:dbbf170529fd6464bda46e314caad686182972a7
  • 8 卡单机
  • deterministic: true
  • AdamW
  • 模型包含 MoE shared expert

最小复现拓扑

parallelism:
  data_parallel_shard: -1
  tensor_parallel: 2
  context_parallel: 4
  pipeline_parallel: 1

training:
  deterministic: true

model:
  experimental_attention_variant: mla
  n_shared_experts: 1

实际并行拓扑为:

data_parallel=1 (dp_shard=1 × dp_replicate=1)
tensor_parallel=2
context_parallel=4

因此 TransformerLayer 虽为 HSDPModule,但参数 shard_size=1,不存在真实 FSDP AllGather。

完整复现配置:

/home/l00913161/lzy/tp2_cp4_determinism_20260903/configs/mla_tp2_cp4_adamw_same_model_100.yaml

使用同一配置、同一 seed 连续运行两次,各训练 100 step,然后逐 step 比较 loss 和 grad_norm。

原始实现的 A/A 结果

MLA、TP2+CP4、AdamW、无重计算,100-step A/A:

指标 首个差异 step MAE P95 最大绝对误差
loss 4 3.6890e-5 1.0100e-4 2.6100e-4
grad_norm 3 3.0893e-4 8.1200e-4 1.4444e-2

拓扑隔离结果:

并行拓扑 loss A/A grad_norm A/A
TP1+CP1 完全一致 完全一致
TP2+CP1 完全一致 完全一致
TP1+CP4 完全一致 完全一致
TP2+CP4 出现漂移 出现漂移

首次分叉证据

首步在 global grad norm 计算前采集 8 rank × 29 参数,共 232 个更新前梯度 SHA,两次运行全部相同,说明 backward 数学计算和 TP reduce 在首步没有产生不同值。

随后使用无 Host 同步快照:每 step 只在 NPU clone 4 个 shared FC 梯度,训练结束后统一计算 SHA。两次运行各得到 3200 条记录:

  • step 1–45:8 rank、4 参数全部一致;
  • step 46:仅 rank 2/3 的 decoder.layers.0.mlp.shared_experts.linear_fc1.weight 首次不同;
  • step 46 的打印 loss/grad_norm 仍一致;
  • step 47:差异扩散到所有 rank 的 shared FC 梯度和 loss/grad_norm;
  • 100 step 共 1730 条 shared 梯度快照不同,54 个训练 step 的轨迹不同。

Prefetch 边界实验

设置 100-step A/A 结果
全部 prefetch 开启 漂移
全部关闭 完全一致
仅关闭 backward prefetch 完全一致
仅关闭 forward prefetch 仍漂移,grad step 26 起
仅保留 tail→last-layer backward prefetch 完全一致
只关闭 layer1→layer0 backward prefetch 完全一致

该结果将首个异常边界定位到后一层 backward pre-hook 对前一层发起的无效 prefetch。

调用链与根因

MindFormers 为相邻 TransformerLayer 建立 backward prefetch 链:

layer1 backward_pre_hook
  -> prefetch_state(layer0).prefetch()
  -> HSDPState.prefetch()
  -> unshard(async_op=True)

基础实现无条件调用:

def prefetch(self) -> None:
    self.unshard(async_op=True)

但 MindSpore 参数 unshard 在 shard_world_size <= 1 时不会发起 collective,只准备或复制本地 buffer,并将 async handle 保持为 None。因此这次 prefetch 没有通信收益,目标层实际进入 backward 时还会再次走同步 unshard。

这里必须以 _init_group_infos() 解析出的 shard_size 判断真实 FSDP communication group 大小,不能使用可能包含原 DTensor/TP 分片语义的 shard_world_size。

期望行为

  • 当一个 HSDP state 的全部参数均为 shard_size=1 时,跳过 prefetch,不提前执行本地 unshard/buffer copy。
  • 任一参数 shard_size>1 时,保留原有异步 prefetch,使真实 FSDP AllGather 能继续与计算重叠。
  • 不改变前向、反向、梯度归约和 optimizer 数学语义。

修复方案

在 MindSpore backend 的 MindSporeHSDPStateV2.prefetch() 中限定:

def prefetch(self) -> None:
    if not any(getattr(param, "shard_size", 1) > 1 for param in self.hsdp_params):
        return
    super().prefetch()

修改放在 MindSpore backend,不改变共享 HSDP 核心逻辑,也不影响 PyTorch backend。

修复验证

  • TP2+CP4、DP/FSDP1:300-step A/A,loss/grad_norm 逐 step 完全一致。
  • MindSpore-backend 最终实现:TP2+CP4 100-step A/A,loss/grad_norm 完全一致。
  • TP2+CP1、DP/FSDP4:100-step A/A 完全一致,真实参数 AllGather prefetch 保持启用。
  • DSA2、TP2+CP4、full recompute,叠加 MindFormers 完整层 replay 修复:300-step loss/indexer_loss/grad_norm A/A 完全一致。
  • 新增单测 2/2 通过:size-one 不调用 async unshard,shard-size=4 仍调用。
  • git diff --check、py_compile 通过。

完整分析报告及原始日志索引:

/home/l00913161/lzy/tp2_cp4_determinism_20260903/TP2_CP4_DETERMINISM_ANALYSIS.md
likedislike
Llzy0920232
9月3日 添加了label:bug
yide12
yide12成员
26 天前 评论:

Appearance & Root Cause

问题现象:MindSpore backend 在 TP2 + CP4、DP/FSDP shard size=1 场景下,使用相同 seed 和相同配置重复训练时,loss 和 grad_norm 会概率性分叉。原始 100-step A/A 中 loss 首次在 step 4 出现差异,最大绝对误差 2.6100e-4;grad_norm 首次在 step 3 出现差异,最大绝对误差 1.4444e-2。

触发条件:MindSpore backend,模块已经 fully_shard/HSDP 包装,但参数 shard group size=1,同时存在跨 TransformerLayer 的 backward prefetch。已通过 prefetch 边界实验定位到 layer1 backward pre-hook 对 layer0 发起的无通信 unshard prefetch;仅关闭 backward prefetch 或仅关闭该边界可恢复 A/A 一致。

根因:原实现用 prefetch_handle is not None 同时表示“存在真实异步 AllGather”和“存在待消费的 unshard”。shard size=1 时没有 collective,异步 prefetch 路径仍会进入本地 unshard,执行 reset_sharded_param()、buffer 准备/拷贝并返回 handle=None。目标层真正进入 backward 时因没有 pending handle 而再次 unshard,导致参数 reset/rebind、DTensor 本地视图和 storage 生命周期与前一层 backward 交叠。底层回调和算子的微小执行时序差异使两次运行出现梯度分叉,随优化器更新扩散到 loss 和 grad_norm。

Fix Solution

修复方案:在 MindSpore 参数层引入 _NoOpAllGatherHandle,将“待消费的 unshard”与“真实通信 handle”分离表达。对 shard size=1 等无 collective 场景,prefetch 阶段只记录逻辑 pending handle,不提前 reset、分配 buffer 或 copy;目标层消费参数时,由 wait_for_unshard() 调用 NoOp handle 的 callback,使用最新参数完成本地 materialization,然后统一执行 init_unsharded_param() 和 to_unsharded()。真实 FSDP AllGather 仍使用 CommHandle,原有异步通信重叠保持不变;同步 collective 即使底层返回 None,也用空 NoOp handle 保留待完成的状态转换。

master 分支的 PR #1326 通过 state 层跳过 size-one prefetch 规避了同一问题;r1.0.0 分支的 PR #1330 采用上述参数生命周期方案完成修复。

Fix Description & Test Suggestion

修复 PR:https://gitcode.com/mindspore/hyper-parallel/merge_requests/1330

PR 状态:已于 2026-09-16 合入 r1.0.0,最终提交为 98a601f69016c2c2c95405cb54dda1133a03fad4,门禁标签为 pr-check-pass 和 ci-pipeline-passed。master 上的对应修复 PR #1326 已于 2026-09-04 合入。

建议回归版本:r1.0.0 分支中包含 PR #1330/提交 98a601f6 的构建;具体 daily 版本号待测试里程碑确认。如回归 master,使用包含 PR #1326 的构建。

测试建议:

  1. 使用 issue 中的 8 卡 MLA、TP2 + CP4、DP/FSDP shard size=1、AdamW、deterministic=true 配置,在最终 #1330 代码上连续执行两次 200 或 300 step,逐 step 比较 loss 和 grad_norm,期望 different_step_count=0 且 max_absolute_error=0。
  2. 确认运行时存在 size-one state 和 layer1→layer0 backward prefetch 边,避免未覆盖问题路径。
  3. 使用 TP2 + CP1、FSDP4 作为对照,确认真实 AllGather prefetch 仍启用且精度一致。

Self-test Report & DT Review

自验环境:MindSpore backend,Ascend 910B;最终修复分支为 r1.0.0 + PR #1330。端到端 A/A 历史实验使用 8 卡 TP2 + CP4、显式 FSDP shard size=1 拓扑。

自验项与结果:自验通过。最终 _NoOpAllGatherHandle 提交的 MindSpore fully_shard UT 结果为 232 passed, 12 skipped,compileall 和 git diff --check 通过,PR 门禁通过。定向 UT 覆盖 size-one async prefetch 不提前 materialize、wait 阶段才 reset/copy/安装参数、NoOp handle 重复 wait 不重复执行、真实 CommHandle 流程不变、同步通信返回 None 时仍保留 pending 状态。此前参数层 reset-guard 候选版本的 8 卡 200-step A/A 结果为 loss/grad_norm 全部一致;该结果不冒充为最终 NoOp handle 提交的端到端结果,回归阶段仍需按上述建议重跑。

是否需要补充 ST/UT:是。UT 已随 PR #1330 补充;由于原问题为概率性长训练轨迹分叉,建议测试阶段在最终合入代码上补做 8 卡 200/300-step A/A ST。

用例路径和用例名:tests/ut/platform/mindspore/fully_shard/test_param.py::TestMindSporeParam::test_no_comm_async_unshard_defers_materialization_until_wait;tests/ut/platform/mindspore/fully_shard/test_mixed_precision.py::TestPrefetchStateMachine::test_sync_collective_without_backend_handle_records_pending_state;tests/ut/platform/mindspore/fully_shard/test_mixed_precision.py::TestPrefetchStateMachine::test_wait_for_unshard_without_pending_work_is_noop。

Introduction Analysis

引入类型:Feature Merge

引入 PR:https://gitcode.com/mindspore/hyper-parallel/merge_requests/240

PR 合入时间:2026-03-14 09:35:34 +08:00

问题是否偶现:是

引入原因:PR #240 首次引入 MindSpore fully_shard backend 时,将底层 AllGather 返回的通信 handle 直接作为 unshard pending 状态。该设计在有真实 AllGather 时成立,但没有覆盖 shard size=1 时“无通信但仍存在待消费 unshard”的状态。原 DT 主要验证单卡 baseline 与多卡 fully_shard 的 loss 容差和 local shape,未覆盖 TP + CP 组合下 DP/FSDP=1、跨层 backward prefetch 和多次长 step A/A 可复现性,因此未在特性合入时识别该交互问题。

likedislike