Prhub

#6765 [worker] feat: add per-step optimizer param overrides

原始 PR 作者 Luosuu 合并时间 2026-06-18 08:25 文件变更 2 提交数 1 评论 1 代码增减 +110 / -8

执行摘要

为 Tinker 工作器添加优化器步骤参数覆盖

扩展 PR #6717 引入的 Tinker 拆分训练原语,让需要显式控制优化器步骤的调用者(如自定义 LR 调度器或梯度累积场景)能够传入运行时的参数覆盖,而不必修改通用的 engine API。来自 PR body 的描述:'This PR extends the Tinker-style worker primitives from #6717 with optional optimizer step parameters for callers that drive optimizer stepping explicitly.'

值得从设计层面关注的问题:为何将参数覆盖限定在 Tinker worker 层而非 engine API(保持 engine 通用性)。类型校验的严格程度(当前选择快速失败而非隐式转换)是一个典型的设计权衡。建议在集成 Tinker 工作流时注意参数类型正确性,并考虑为 Megatron 等后端补充类似的集成测试。

讨论亮点

gemini-code-assist[bot] 指出 _apply_optim_step_params 中对值类型的严格检查(isinstance(value, expected_type))可能导致常见参数覆盖失败,例如用户传递 weight_decay: 0(int 而非 float)或配置解析将 betas 加载为 list 而非 tuple。建议对兼容类型做归一化(int→float, list→tuple),以增强鲁棒性。但该建议未在 PR 中采纳,保持严格类型检查,以避免隐藏更严重的类型不匹配问题。

实现拆解

  1. 定义参数覆盖类型:在 verl/workers/engine_workers_tinker.py 中新增 OptimStepParams (TypedDict, total=False),声明可覆盖的字段(lrepsbetasweight_decay),并编写文档说明仅用于 Tinker optimizer_step 场景。

  2. 实现参数组展平辅助函数 _iter_optimizer_param_groups:该函数判断优化器是否为 VeOmni MultiOptimizer(通过 _is_multi_optimizer 属性),若是则展平所有子优化器的 param_groups,否则直接返回单个优化器的参数组;对不暴露 param_groups 的优化器抛出 NotImplementedError

  3. 实现参数覆盖应用函数 _apply_optim_step_params:接受优化器和覆盖字典,先处理 to_dict 兼容性,过滤掉 None 值;遍历所有参数组,校验每个覆盖键的存在性和类型一致性(所有参数组的对应值和键类型必须一致),最后逐个更新参数组。该函数在优化器步骤前调用。

  4. 修改 TinkerTrainingWorker.optimizer_step:增加可选的 optim_step_params 参数(默认 None),在调用 engine 的 optimizer_step 之前调用 _apply_optim_step_params 应用覆盖;同时将 LR scheduler 步骤分离为可选(update_lr_scheduler 参数),让调用者自行控制 LR 调度。

  5. 修复 TinkerActorRolloutRefWorker.forward_backward:更正为通过 self.actor.forward_backward 调度到 actor 网格,而非直接调用基类方法。

  6. 测试验证:在 tests/models/test_engine.py 的 FSDP 拆分训练测试中,添加对 _apply_optim_step_params 的集成验证:构造完整的 OptimStepParams 字典,执行 step 后断言所有参数组的 lrbetasepsweight_decay 被正确覆盖。

文件 模块 状态 重要度
verl/workers/engine_workers_tinker.py 拆分训练 modified 8.54
tests/models/test_engine.py 引擎测试 modified 4.86

关键符号

TinkerTrainingWorker.optimizer_step _apply_optim_step_params _iter_optimizer_param_groups TinkerActorRolloutRefWorker.forward_backward

关键源码片段

verl/workers/engine_workers_tinker.py core-logic

核心实现文件,新增 OptimStepParams 类型、展平参数组辅助函数、参数覆盖应用函数,并修改 optimizer_step 方法支持覆盖参数。

# 关键类型定义:OptimStepParams 是传递给 optimizer_step 的可选覆盖 payload
class OptimStepParams(TypedDict, total=False):
    """
    运行时参数组覆盖,仅用于 TinkerTrainingWorker.optimizer_step。    当前实现将所有键应用到所有参数组;对于 VeOmni MultiOptimizer 则展平子优化器后统一覆盖。
    """
    lr: float
    eps: float
    betas: tuple[float, float]
    weight_decay: float
​
​
def _iter_optimizer_param_groups(optimizer):
    """返回展平后的参数组列表,包括 VeOmni MultiOptimizer 的子优化器。"""
    # 通过 duck typing 检测是否为 MultiOptimizer
    if getattr(optimizer, "_is_multi_optimizer", False):
        optimizers = optimizer.optimizers_dict.values()
    else:
        optimizers = [optimizer]
​
    param_groups = []
    for opt in optimizers:
        opt_param_groups = getattr(opt, "param_groups", None)
        if opt_param_groups is None:
            raise NotImplementedError(
                f"{type(opt).__name__} 未暴露 param_groups,无法执行逐步骤覆盖"
            )
        param_groups.extend(opt_param_groups)
    return param_groups
​
​
def _apply_optim_step_params(optimizer, optim_step_params: OptimStepParams | None) -> None:
    """
    将 Tinker 步骤级覆盖应用到每个优化器参数组。    覆盖是全局的:每个提供的键必须存在于所有参数组且类型一致。
    这可以让混合优化器(如 VeOmni Muon+AdamW)在遇到优化器专有键(如 betas)时快速失败,
    同时允许共享键(如 lr)正常生效。
    """
    if optim_step_params is None:
        return
​
    # 处理可能携带 to_dict 的兼容类型
    if hasattr(optim_step_params, "to_dict"):
        optim_step_params = optim_step_params.to_dict()
    if not isinstance(optim_step_params, dict):
        raise TypeError(f"optim_step_params 必须是 dict,但得到了 {type(optim_step_params)}")
​
    # 过滤掉 None 值,避免干扰参数组
    normalized_params = {key: value for key, value in optim_step_params.items() if value is not None}
    if not normalized_params:
        return
​
    param_groups = _iter_optimizer_param_groups(optimizer)
    if not param_groups:
        raise ValueError(f"{type(optimizer).__name__} 没有 param_groups")
​
    # 校验每个覆盖键的类型与第一个参数组一致,且所有参数组都有相同键和类型
    for key, value in normalized_params.items():
        if key not in param_groups[0]:
            raise ValueError(f"{type(optimizer).__name__} 不支持覆盖键: {key!r}")
        expected_type = type(param_groups[0][key])
        if not isinstance(value, expected_type):
            raise TypeError(
                f"覆盖参数类型不匹配: 键 {key!r} 期望 {expected_type.__name__},得到了 {type(value).__name__}"
            )
        # 确保所有参数组的一致性
        for param_group in param_groups:
            if key not in param_group:
                raise ValueError(f"参数组中缺少键 {key!r}")
            if not isinstance(param_group[key], expected_type):
                raise TypeError(f"参数组中键 {key!r} 类型不一致")
​
    # 应用覆盖到所有参数组
    for param_group in param_groups:
        param_group.update(normalized_params)

评论区精华

类型检查严格性导致潜在兼容问题 正确性

gemini-code-assist[bot] 指出 `_apply_optim_step_params` 中使用 `isinstance(value, expected_type)` 会导致常见参数覆盖失败,例如用户传递 `weight_decay: 0`(int 而非 float)或配置解析将 `betas` 加载为 list 而非 tuple。建议对兼容类型做归一化(int→float, list→tuple),以增强鲁棒性。

结论:PR 合并时未采纳该建议,保持严格类型检查。 · 已解决

风险与影响

  1. 严格类型检查风险:调用者必须确保参数值与优化器参数组中类型完全一致(如 float、tuple),否则会触发 Runtime TypeError。这可能对动态配置或框架转换不够友好,但设计者认为快速失败优于静默兼容。
  2. VeOmni 兼容性_iter_optimizer_param_groups 将多优化器展平后统一覆盖,若子优化器存在不同键或类型不一致,会抛出异常。该行为在文档中明确。
  3. Scheduler 解耦:LR scheduler 步骤已从 optimizer_step 中分离,依赖原有默认参数 update_lr_scheduler=True 的调用者需检查是否受影响。好在 PR 保持默认行为不变。
  4. 修复的调度问题TinkerActorRolloutRefWorker.forward_backward 的修复涉及复合 worker 路由,若其他代码有类似假设可能产生到 actor 路由错误,但已有测试覆盖。

用户影响:仅影响使用 TinkerTrainingWorker 并传递 optim_step_params 的显式调用者。默认 train_batch 路径完全不变,因此绝大多数现有用户无感知。对于 Tinker 用户,该功能提供了更细粒度的优化器控制能力,但需注意严格类型匹配。

系统影响:不涉及配置、部署或监控变更。VeOmni 用户的覆盖行为与标准优化器一致。

团队影响:低;代码集中在少量文件,测试覆盖了 FSDP 策略,其他后端(Megatron、TorchTitan)需额外手动验证。

严格类型检查 VeOmni 兼容性 Scheduler 解耦

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论