Prhub

#3245 fix(megatron): respect --no-load-optim when resuming a LoRA adapter

原始 PR 作者 Zhichenzzz 合并时间 2026-09-15 10:55 文件变更 3 提交数 3 评论 0 代码增减 +57 / -2

执行摘要

修复 LoRA 恢复时忽略 --no-load-optim 导致旧优化器状态误载

PR body 指出:_load_training_state 只要发现 adapter 旁有 training_state_rank*.pt 就恢复 optimizer,而 load_lora_adapter 从未接收 args,因此 --no-load-optim 无法传入。更严重的是,在非 --megatron-to-hf-mode bridge 模式下,miles_validate_args 会在 --load 没有 tracker 文件时自动设置 no_load_optim = True,即用户本意是从全新 optimizer 开始,却通过 adapter 把旧的 Adam moments 悄悄带回来。这与 Megatron 原生 load_checkpoint 及 fsdp_utils/checkpoint.py 对 --no-load-optim 的语义不一致,属于静默状态污染 bug。

值得精读,但定位是精准的小范围 bugfix。重点关注三点:一是 load_optimizer 参数默认值选择(True)如何保证向后兼容;二是 --no-load-optim 语义被严格限定为“只跳过 optimizer,保留 iteration 与 scheduler”,与 Megatron 原生行为对齐;三是通过 entry 存在性守卫实现与 #3083 的无序合入,这是一种低成本的跨 PR 兼容设计。若团队后续会扩展 LoRA 恢复功能,可继续关注 load_lora_adapter 的参数演进。

讨论亮点

该 PR 没有实质代码评审讨论:review_comments_count=0,仅有 claude[bot] 的自动提示(仓库配置为手动 review,可 @claude review)和 yueming-yuan 的直接 approve。设计要点全部体现在 PR body 与 commit message 中:作者明确说明恢复被守卫在 entry 存在性上,因此与 #3083 可任意顺序合入;并强调 --no-load-optim 仍恢复 iteration 与 LR scheduler,以对齐 Megatron 原生 load_checkpoint 和 fsdp_utils/checkpoint.py 的既有语义。

实现拆解

  1. 调用点接线(miles/backends/megatron_utils/checkpoint.py):在 load_checkpoint 的 LoRA 分支中,调用 load_lora_adapter 时新增 load_optimizer=not args.no_load_optim,把 Megatron 既有 flag 的语义透传到 LoRA 恢复路径,这是整个修复的入口。
  2. 参数透传(miles/backends/megatron_utils/lora_utils.py):load_lora_adapter 新增关键字参数 load_optimizer: bool = True(默认保持旧行为,避免影响其他调用方),并在 native 格式命中分支中把它传给 _load_training_state;_load_training_state 同样新增 load_optimizer: bool = True。
  3. 门控逻辑:_load_training_state 中,当 load_optimizer=False 时跳过 optimizer.load_state_dict,仅记录 --no-load-optim: keeping the freshly initialized optimizer 日志,但 opt_param_scheduler 和 iteration 仍照常恢复——这正是 Megatron 自身对 --no-load-optim 的定义。
  4. 跨 PR 兼容守卫(第二个 commit):将 optimizer 恢复改为先检查 training_state.get("optimizer") is not None 再执行 load_state_dict,避免 #3083 使 --no-save-optim 写出 null optimizer 后,此处把 None 传给 load_state_dict;该守卫让两个 PR 以任意顺序合入都安全。
  5. 测试配套(tests/fast/backends/megatron_utils/test_lora_utils.py):新增 TestLoadTrainingStateOptimizerGate,两个用例分别验证 _load_training_state(..., load_optimizer=False) 不触达 optimizer 但恢复 scheduler/iteration,以及 load_lora_adapter(..., load_optimizer=False) 能正确透传 flag;全程无 GPU。验证结果:fast 测试 318 passed / 2 skipped,pre-commit 干净,4x H200 Qwen2.5-0.5B LoRA rank 16 GRPO 端到端手动验证两种 resume 方式符合预期。
文件 模块 状态 重要度
miles/backends/megatron_utils/lora_utils.py 适配器层 modified 5.91
miles/backends/megatron_utils/checkpoint.py 检查点 modified 4.75
tests/fast/backends/megatron_utils/test_lora_utils.py 单元测试 modified 6.27

关键符号

load_lora_adapter _load_training_state load_checkpoint

关键源码片段

miles/backends/megatron_utils/lora_utils.py core-logic

核心修复文件:`load_lora_adapter` 和 `_load_training_state` 新增 `load_optimizer` 参数,控制 resume 时是否恢复 optimizer,同时保留 iteration 与 LR scheduler 恢复。

def load_lora_adapter(
    model: Sequence[torch.nn.Module],
    adapter_path: str,
    *,
    optimizer: Any | None = None,
    opt_param_scheduler: Any | None = None,
    load_optimizer: bool = True, # 默认 True 保持旧行为,只有 load_checkpoint 显式传入 flag
) -> tuple[bool, int | None]:
    """Load LoRA adapter weights from a saved checkpoint into the model."""
    adapter_dir = Path(adapter_path)
    if not adapter_dir.exists():
        logger.warning(f"LoRA adapter path does not exist: {adapter_dir}")
        return False, None
​
    tp_rank = get_parallel_state().tp.rank
    pp_rank = get_parallel_state().pp.rank
​
    # ---- 优先尝试 Megatron-native 格式(保 TP/PP 分片,无需转换) ----
    global_rank = dist.get_rank() if dist.is_initialized() else 0
    native_path = adapter_dir / f"adapter_megatron_rank{global_rank}.pt"
    if not native_path.exists():
        legacy = adapter_dir / f"adapter_megatron_tp{tp_rank}_pp{pp_rank}.pt"
        if legacy.exists():
            logger.warning(f"Using legacy tp/pp-named adapter shard {legacy}; only valid when EP<=TP")
            native_path = legacy
    if native_path.exists():
        state_dict = torch.load(native_path, map_location="cpu", weights_only=True)
        loaded = 0
        for model_chunk in model:
            for name, param in model_chunk.named_parameters():
                if name in state_dict:
                    param.data.copy_(state_dict[name].to(device=param.device))
                    loaded += 1
        logger.info(f"Loaded {loaded} adapter tensors from Megatron-native checkpoint: {native_path}")
​
        # 把 load_optimizer 透传给训练状态恢复,决定是否跳过 optimizer
        iteration = _load_training_state(adapter_dir, optimizer, opt_param_scheduler, load_optimizer)
        return True, iteration
​
    # ---- HF PEFT 格式(尚未支持直接加载) ----
    hf_path = adapter_dir / "adapter_model.bin"
    if hf_path.exists():
        logger.warning(
            f"Found HF PEFT adapter at {hf_path} but direct HF PEFT loading into "
            f"Megatron is not yet supported. Please save using Megatron-native format "
            f"(adapter_megatron_tp*_pp*.pt files) for checkpoint resume."
        )
        return False, None
​
    logger.warning(f"No adapter checkpoint found at {adapter_dir}")
    return False, None
​
​
def _load_training_state(
    adapter_dir: Path,
    optimizer: Any | None,
    opt_param_scheduler: Any | None,
    load_optimizer: bool = True,
) -> int | None:
    """Restore optimizer/scheduler state saved alongside a LoRA adapter checkpoint.
​
    load_optimizer=False 对应 --no-load-optim:保留全新 optimizer,
    但仍然恢复 LR scheduler 与 iteration,与 Megatron 原生语义一致。
    """
    if optimizer is None:
        return None
​
    rank = dist.get_rank() if dist.is_initialized() else 0
    state_path = adapter_dir / f"training_state_rank{rank}.pt"
    if not state_path.exists():
        return None
​
    # optimizer state dict 可能含非 tensor 对象(如 step 计数、param group 元数据),
    # 因此这里必须完整反序列化,不能 weights_only=True。
    training_state = torch.load(state_path, map_location="cpu", weights_only=False)
​
    if not load_optimizer:
        # --no-load-optim:明确跳过,避免旧 Adam moments 污染全新 optimizer
        logger.info("--no-load-optim: keeping the freshly initialized optimizer")
    elif training_state.get("optimizer") is not None:
        # entry 存在性守卫:兼容 #3083 的 --no-save-optim 写出 null optimizer 的场景
        optimizer.load_state_dict(training_state["optimizer"])
        logger.info("Restored optimizer state from LoRA checkpoint")
​
    if opt_param_scheduler is not None and training_state.get("opt_param_scheduler") is not None:
        opt_param_scheduler.load_state_dict(training_state["opt_param_scheduler"])
        logger.info("Restored LR scheduler state from LoRA checkpoint")
​
    iteration = training_state.get("iteration")
    if iteration is not None:
        logger.info(f"Resuming LoRA training from iteration {iteration}")
    return iteration
miles/backends/megatron_utils/checkpoint.py core-logic

修复的入口接线点:`load_checkpoint` 在 LoRA 分支中把 `not args.no_load_optim` 传给 `load_lora_adapter`,使 flag 首次真正到达 LoRA 恢复逻辑。

    # Load LoRA adapter weights if available
    if is_lora_enabled(args):
        adapter_path = getattr(args, "lora_adapter_path", None)
        if adapter_path is not None:
            loaded, iteration = load_lora_adapter(
                ddp_model,
                adapter_path,
                optimizer=optimizer,
                opt_param_scheduler=opt_param_scheduler,
                # 把 Megatron 的 --no-load-optim 语义透传进来:
                # False 时保留全新 optimizer,但仍恢复 iteration 与 LR scheduler
                load_optimizer=not args.no_load_optim,
            )
            if loaded:
                logger.info(f"Successfully loaded LoRA adapter from {adapter_path}")
                if iteration is not None:
                    # 用 LoRA 训练状态中的 iteration 覆盖主 checkpoint 的恢复结果
                    result = (iteration, result[1])
            else:
                logger.warning(
                    f"LoRA is enabled and --lora-adapter-path={adapter_path} was specified, "
                    f"but adapter weights could not be loaded. "
                    f"Training will start with freshly initialized adapter weights."
                )
​
    return result

评论区精华

自动化 review 提示与 approve(无实质技术讨论) other

claude[bot] 自动提示该仓库配置为手动 code review,可 `@claude review` 触发;yueming-yuan 直接 approve,无内联评论。PR body 与 commit message 承载了全部设计决策。

结论:无技术争议,作者自审后合入。 · 已解决

风险与影响

  1. 行为变更面:load_lora_adapter 默认参数 load_optimizer=True 保持旧行为,但 load_checkpoint 是唯一调用点且显式传入 flag,因此所有走进 load_checkpoint 的 LoRA 恢复路径都会受 args.no_load_optim 影响——这正是预期修复,但若有外部代码绕过 load_checkpoint 直接调用 load_lora_adapter,仍会默认恢复 optimizer。
  2. 属性存在性风险:checkpoint.py 直接读取 args.no_load_optim,若未来有调用路径构造的 args 缺少该属性会抛 AttributeError;当前 miles_validate_args 保证其存在,但测试未覆盖该属性缺失的防御。
  3. 跨 PR 依赖:第二个 commit 的 entry 守卫虽让 #3083 与 #3245 顺序无关,但如果 #3083 的写入格式后续再变化(例如 key 改名),此处仍可能静默退化。
  4. 测试覆盖局限:新增测试为 fast 单测,端到端验证仅在 4x H200 上手动执行,未固化进 CI;对 LR scheduler 恢复语义的断言依赖 state_dict 直接比较,未覆盖真实 Adam moment 的格式兼容。

影响范围集中在 Megatron 后端的 LoRA 训练恢复路径:使用 --lora-adapter-path 恢复且同时指定 --no-load-optim(或由 miles_validate_args 自动设置该 flag)的用户,此前会错误恢复旧 optimizer 状态,修复后 optimizer 保持全新,但 iteration 与 LR scheduler 仍正确恢复。对不使用 LoRA 或未开启 --no-load-optim 的运行无行为变化。团队收益在于:LoRA RL 实验的恢复语义与 Megatron/FSDP 主路径统一,避免静默的优化器状态污染导致的实验结论失真。

恢复路径行为变更 依赖 args.no_load_optim 属性存在 跨 PR 兼容依赖 #3083 端到端验证未固化进 CI

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论