Prhub

#1966 [Bug Fix][OPD] Stop gradients through old-policy and teacher scores

原始 PR 作者 ChenyangWangjohn 合并时间 2026-08-09 23:34 文件变更 5 提交数 3 评论 2 代码增减 +278 / -24

执行摘要

OPD 固定打分统一切断梯度,防计算图泄漏

PR body 明确指出:OPD 和 PPO 式策略梯度训练把 teacher、reference-policy、pre-update policy、rollout-policy 与 advantage 张量当作固定训练输入;标准打分路径不会产生梯度,但自定义与融合路径可能破坏该契约。若缺少显式边界,一个“活的”旧策略张量可能进入 PPO/GSPO 的 importance ratio,在可微的 current-policy 分支旁保留不必要的计算图。本 PR 的目标就是在训练边界强制该契约。

值得精读。该 PR 是训练框架中“固定输入边界”的典型实现,适合负责自定义 loss、TIS、rollout 数据流或 OPD 蒸馏的同学阅读;重点关注 compute_advantages_and_returns 的持久化 detach 与 policy_loss_function 的防御性 detach 如何互补,以及自定义 TIS 兼容性的取舍。

讨论亮点

本 PR 无实质 review 技术讨论。两位 reviewer 均 APPROVED:Shi-Dong 给出“LGTM”,maocheng23 无备注;两个 gemini-code-assist 机器人评论仅提示 Gemini Code Assist 消费者版本已停用。PR 作者在 body 中说明了关键设计权衡:teacher 打分在持久数据中保持原设备、设备对齐局部化到 OPD 计算,以最小化对既有数据流的影响;自定义 TIS 保留不消费 trainer-scored log-probs 的兼容性,而内置 TIS 保留严格输入校验。无未解决疑虑。

实现拆解

  1. 持久化边界统一切断计算图:在 miles/backends/training_utils/loss.py 新增模块级辅助函数 _detach_rollout_tensor_list(rollout_data, key),对列表中每个张量调用 detach() 并写回 rollout_datacompute_advantages_and_returns() 在非末级 pipeline 检查之后、KL 与 advantage 估计之前,依次对 log_probsrollout_log_probsref_log_probsteacher_log_probs 调用该函数,使 rollout 阶段产出的所有打分成为固定训练数据。

  2. 损失消费边界防御性 detach:在 miles/backends/training_utils/loss_hub/losses.pypolicy_loss_function() 中,advantagestorch.cat 前逐张 detach;scored_old_log_probsrollout_old_log_probsreference_log_probs 均从 batch 中生成 detached 副本,并按 use_rollout_logprobs 选择 old_log_probs,同时补充明确 assert,缺失必备输入时直接报错。这样即使调用方绕过持久化边界,也不会把图带进 importance ratio。

  3. OPD 局部设备对齐与 detachloss_hub/opd.pyapply_opd_kl_to_advantages() 中,teacher 打分先 detach 并写回 rollout_data 保持原始设备,仅在做 reverse-KL 前临时搬到 student 设备;预计算 opd_reverse_kl 分支同样先 detach 再 to(device),防止直接调用者绕过 compute_advantages_and_returns 时泄漏梯度。

  4. 消费者适配与校验收紧:TIS 分支区分自定义与内置实现——自定义 TIS 允许 train_log_probs 缺失,内置 vanilla_tis_function 则严格断言 scored_old_log_probsrollout_old_log_probs 都存在;use_kl_loss 分支断言 ref_log_probs 存在;debug dump、mismatch metrics 与 train_rollout_logprob_abs_diff 全部改用 detached 副本。

  5. 测试配套test_true_on_policy_loss_metrics.py 新增 3 个 backward 级测试,验证 policy loss 只通过 current-policy logits 获得梯度(覆盖 use_rollout_logprobs 两种取值)、reference KL 路径不回传梯度、自定义 TIS 可容忍缺失 trainer-scored log-probs;test_opd.py 新增持久化 rollout 数据全字段 detached 断言与预计算 reverse-KL 不回流梯度的测试。

文件 模块 状态 重要度
miles/backends/training_utils/loss.py 损失模块 modified 6.56
miles/backends/training_utils/loss_hub/losses.py 损失中心 modified 6.06
miles/backends/training_utils/loss_hub/opd.py OPD 模块 modified 5.09
tests/fast/backends/training_utils/test_true_on_policy_loss_metrics.py 损失测试 modified 6.66
tests/fast/backends/training_utils/loss/test_opd.py OPD 测试 modified 5.91

关键符号

_detach_rollout_tensor_list compute_advantages_and_returns policy_loss_function apply_opd_kl_to_advantages

关键源码片段

miles/backends/training_utils/loss.py core-logic

核心源码主路径:新增 `_detach_rollout_tensor_list`,并在 `compute_advantages_and_returns` 入口统一 detach 四类固定打分张量,是本次修复的持久化边界。

# 持久化边界统一切断计算图:rollout 阶段产出的打分是固定训练数据,
# 不应携带任何 autograd graph 进入策略更新阶段。
def _detach_rollout_tensor_list(rollout_data: RolloutBatch, key: str) -> list[torch.Tensor] | None:
    tensors = rollout_data.get(key)
    if tensors is None:
        return None
    detached_tensors = [tensor.detach() for tensor in tensors]
    rollout_data[key] = detached_tensors
    return detached_tensors
​
​
def compute_advantages_and_returns(args: Namespace, rollout_data: RolloutBatch) -> None:
    log_probs_key = "rollout_log_probs" if args.use_rollout_logprobs else "log_probs"
    log_probs: list[torch.Tensor] = rollout_data.get(log_probs_key)
    values = rollout_data.get("values")
​
    # 非最后一个 pipeline 阶段没有完整打分,直接返回,不触发 detach。
    if log_probs is None and values is None:
        return
​
    # 权威持久化边界:先切断旧策略、rollout 策略、参考策略与 teacher 打分的
    # 计算图,再计算 KL 与 advantage,保证固定输入契约对所有打分路径生效。
    _detach_rollout_tensor_list(rollout_data, "log_probs")
    _detach_rollout_tensor_list(rollout_data, "rollout_log_probs")
    _detach_rollout_tensor_list(rollout_data, "ref_log_probs")
    _detach_rollout_tensor_list(rollout_data, "teacher_log_probs")
​
    # detach 之后重新读取,后续 KL 与 advantage 计算都基于固定输入。
    log_probs = rollout_data.get(log_probs_key)
    ref_log_probs = rollout_data.get("ref_log_probs")
​
    if args.kl_coef == 0 or not log_probs:
        xs = log_probs if log_probs is not None else values
        kl = [torch.zeros_like(x, dtype=torch.float32, device=x.device) for x in xs]
    else:
        kl = [
            compute_approx_kl(log_probs[i], ref_log_probs[i], kl_loss_type=args.kl_loss_type)
            for i in range(len(log_probs))
        ]
​
    advantages, returns = compute_advantages(
        args=args,
        kl=kl,
        rewards=rollout_data.get("rewards"),
        log_probs=log_probs,
        loss_masks=rollout_data.get("loss_masks"),
        total_lengths=rollout_data.get("total_lengths"),
        response_lengths=rollout_data.get("response_lengths"),
        max_seq_lens=rollout_data.get("max_seq_lens", None),
        values=values,
    )
miles/backends/training_utils/loss_hub/losses.py core-logic

核心消费边界:`policy_loss_function` 对 advantages 与各类固定打分做防御性 detach,并调整 TIS、KL loss 分支的输入校验,是梯度边界的第二道防线。

# 消费边界防御:即使调用方没有在持久化边界 detach,
# 这里也强制 advantages 与各类固定打分脱离计算图。
def policy_loss_function(
    args: Namespace,
    batch: RolloutBatch,
    logits: torch.Tensor,
    sum_of_sample_mean: Callable[[torch.Tensor], torch.Tensor],
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
    parallel_state = get_parallel_state()
    advantages_list = [advantage.detach() for advantage in batch["advantages"]]
    advantages = torch.cat(advantages_list, dim=0)
    scored_old_log_probs = (
        [log_prob.detach() for log_prob in batch["log_probs"]] if batch.get("log_probs") is not None else None
    )
    rollout_old_log_probs = (
        [log_prob.detach() for log_prob in batch["rollout_log_probs"]]
        if batch.get("rollout_log_probs") is not None
        else None
    )
    reference_log_probs = (
        [log_prob.detach() for log_prob in batch["ref_log_probs"]] if batch.get("ref_log_probs") is not None else None
    )
​
    # 按配置选择 importance ratio 的旧策略打分,缺输入时直接报错而不是静默失败。
    if args.use_rollout_logprobs:
        assert rollout_old_log_probs is not None, "rollout_log_probs must be provided when --use-rollout-logprobs is set"
        old_log_probs = rollout_old_log_probs
    else:
        assert scored_old_log_probs is not None, "log_probs must be provided for policy loss"
        old_log_probs = scored_old_log_probs
​
    # current-policy 的 log_probs 仍由可微 logits 计算,梯度只应流经这一条分支;
    # 后续 TIS、KL loss、debug dump 与 mismatch metrics 全部使用上述 detached 副本。
    log_probs_and_entropy = get_log_probs_and_entropy(
        logits,
        args=args,
        unconcat_tokens=batch["unconcat_tokens"],
        total_lengths=batch["total_lengths"],
        response_lengths=batch["response_lengths"],
        with_entropy=args.entropy_coef != 0 or args.observe_training_entropy,
        entropy_requires_grad=args.entropy_coef != 0,
        max_seq_lens=batch.get("max_seq_lens", None),
    )
    log_probs = log_probs_and_entropy["log_probs"]
miles/backends/training_utils/loss_hub/opd.py core-logic

OPD 蒸馏的局部实现:teacher 打分与预计算 reverse-KL 增加 detach,并保持持久数据的原始设备,是 OPD 路径梯度泄漏的修复点。

# OPD 的 reverse-KL 惩罚:student 与 teacher 打分都是固定输入,
# 这里对直接调用者做防御性 detach,避免绕过 compute_advantages_and_returns 时泄漏梯度。
def apply_opd_kl_to_advantages(
    args: Namespace,
    rollout_data: RolloutBatch,
    advantages: list[torch.Tensor],
    student_log_probs: list[torch.Tensor] | None,
) -> None:
    if student_log_probs is None:
        return
​
    precomputed_reverse_kls = rollout_data.get("opd_reverse_kl")
    if precomputed_reverse_kls is not None:
        reverse_kls = []
        for i, adv in enumerate(advantages):
            reverse_kl = precomputed_reverse_kls[i]
            if not torch.is_tensor(reverse_kl):
                reverse_kl = torch.tensor(reverse_kl, dtype=torch.float32)
            # 防御性消费边界:预计算 reverse-KL 也必须脱离计算图。
            reverse_kl = reverse_kl.detach().to(device=adv.device)
            advantages[i] = adv - args.opd_kl_coef * reverse_kl
            reverse_kls.append(reverse_kl)
        rollout_data["opd_reverse_kl"] = reverse_kls
        return
​
    teacher_log_probs = rollout_data.get("teacher_log_probs")
    if teacher_log_probs is None:
        raise ValueError(f"OPD with opd_type='{args.opd_type}' requires teacher_log_probs, but it is missing.")
​
    # teacher 打分保持原设备写回持久数据,仅 OPD 计算前临时搬到 student 设备,
    # 避免设备迁移副作用污染持久化数据。
    device = student_log_probs[0].device
    detached_teacher_log_probs = [t.detach() for t in teacher_log_probs]
    rollout_data["teacher_log_probs"] = detached_teacher_log_probs
    teacher_log_probs = [t.to(device=device) for t in detached_teacher_log_probs]
​
    reverse_kls = []
    for i, adv in enumerate(advantages):
        # 旧 student 打分同样视为固定输入,reverse-KL 不能反向传播到打分源。
        old_student_log_prob = student_log_probs[i].detach()
        reverse_kl = old_student_log_prob - teacher_log_probs[i]
        advantages[i] = adv - args.opd_kl_coef * reverse_kl
        reverse_kls.append(reverse_kl)
    rollout_data["opd_reverse_kl"] = reverse_kls

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  • 梯度语义变更:任何非标准地依赖 old-policy / teacher 打分回传梯度的自定义 loss 将不再获得梯度,属预期 breaking change,需在文档与升级说明中强调。
  • 自定义 TIS 兼容性:assert "rollout_log_probs" in batch 被替换为分支内校验,自定义 TIS 不再强制要求 train_log_probs;若某个自定义 TIS 隐式依赖该键存在,可能从显式报错变为静默拿到 None。
  • 设备迁移语义:持久化 teacher_log_probs 保持在原设备,OPD 内临时迁移;依赖调用后 rollout_data["teacher_log_probs"] 已被搬到 student 设备的旧代码,行为会发生变化。
  • 回归风险:核心 loss 路径变更影响 PPO/GSPO/TIS/KL 全链路,测试覆盖了常见路径,但真实多卡 / pipeline 并行拓扑下的验证仅靠手工运行官方 Qwen3-8B / 32B OPD recipe。
  • 用户:所有使用 OPD 蒸馏或 PPO/GSPO 策略梯度训练的团队;修复后 loss 反向传播只经过 current-policy 分支,数值语义不变但梯度来源更干净。
  • 系统:切断多余计算图可减少反向传播时间和显存占用,尤其在长序列 + MoE 大规模训练中收益更明显。
  • 团队:确立“持久化边界 + 消费边界”双层 detach 契约,后续新增自定义 scorer、融合打分路径或自定义 TIS 时都有明确的安全网。
  • 影响程度:中高——代码集中在训练核心路径,但改动量小、行为语义(OPD 目标、系数、裁剪)保持不变。
核心训练路径变更 自定义 TIS 行为变化 设备迁移语义变化

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论