Skip to content

【Task.27】解耦算法配置接入 GDPO - RFC #218

Description

@Men1scus
字段 内容
RFC 状态 Draft —— 已有完整参考实现 + 全套 CPU 测试,待维护者设计评审
对应任务 Relax 贡献者计划 2026 · 第一期 · 高级任务 27
上游仓库 redai-infra/Relax
开发 fork Men1scus/Relax
基线 commit 039ce876d25540adad847d4223b4de4722d8f425main,与 RFC #214/#105 同一基线,可直接对比)
实现 commit b7cb881(分支 feat/algorithm-registry-gdpo,24 次提交;末次为本轮评审后的文档/注释修订)
测试佐证 tests/algorithms/ 本机实测 690 passed(含 2 进程 gloo 分布式白化);pre-commit run --all-files 全部通过
最后更新 2026-08-02

1. 摘要

Relax 当前把「算法名称 → reward 归一化 → advantage 公式 → policy loss 公式 → 服务角色拓扑 → 两轮参数校验」这套映射,分散在 6 个文件里用字符串列表和 if/elif 链各自维护一份。新增算法要同时改到所有地方,容易出现「parser 已接受该算法,但 Controller 或 loss 路径没接上」的静默漏接——reinforce_plus_plus 就是现成的例子:公式在别处都实现了、argparse 也接受,却因为 ALGOS 表里漏了它,一用就在 controller.register_all_serve 崩掉。

本方案建立一个声明式算法注册表:每个算法在 AlgorithmSpec 里注册一次名称、三段实现标识符(reward / advantage / policy)和一组能力标志;CLI choices、Controller 角色拓扑、reward 后处理、advantage 计算、policy loss 分发、参数校验全部改为查询同一份 spec,不再各自维护算法名单。

GDPO 在这套模型里只是一条注册项 + 它的纯函数:对每个配置的 reward key 分别做 prompt-group 组内标准化、按权重合并,再对整个训练 batch 做 sample-level 白化,最后把每条序列的标量 advantage 广播到 response token。实现复用现有 Sample.rewardpost_process_rewards()、TransferQueue、Advantages service 和 Megatron loss——不新增 service、worker、依赖或传输字段,也不改 checkpoint 格式,通用路径里没有任何 if algorithm == "gdpo"

本 RFC 描述的设计已在 fork 上完整实现tests/algorithms/ 690 个 CPU 用例全绿(含真 2 进程 gloo);正文每个设计决策都可在实现 commit 中逐条核对。少量措辞/注释修订与计划内工作在 §11 如实列出。


2. 问题定义

2.1 同一决策被多个模块重复拥有

在基线 039ce87 上,同一份算法知识散落在这些位置:

层次 基线现状 典型后果
CLI --advantage-estimator 手工维护 choices 列表 新算法可能压根不被 parser 接受
服务拓扑 relax/core/registry.pyALGOS 手写算法→组件映射 parser 接受但服务拓扑缺失 → 启动崩溃
Reward relax/utils/utils.py:post_process_rewards() 维护算法名单 + 归一化分支 reward 公式与算法定义分离
Advantage relax/components/advantages.pyrelax/backends/megatron/loss.py 各维护一份 if/elif 两条执行路径容易行为漂移
Policy loss GSPO / SAPO / CISPO 在 loss 里按名称分支选 ratio/objective 新目标函数必须侵入通用 loss
参数校验 arguments.py 两轮校验各自硬编码算法名 校验与实现脱节

问题的本质不是字符串本身,而是「同一个决策被多个模块各自拥有一份副本」。只加一个新的 dispatch helper 并不能消除漏接——只要 CLI、能力判断、服务视图还各有各的名单,遗漏就还会发生。

2.2 GDPO 带来的新约束

标量 GRPO 是「先把多个 reward 加起来、再做一次组内归一化」,于是组内总奖励恒定的一组 rollout 会被整组归零,无论各分量内部有没有差异。GDPO 对每个分量独立做组内标准化再合并,让每个分量的信号各自存活到合并那一步。

这里要说清一个边界,而不是把它讲成万灵药——本 RFC 早前的版本正是在这里讲错了:

  • 两个分量、等权重、总和恒定时,GDPO 同样归零,而且这是必然的format = C − correctness,标准化后两列精确互为相反数,等权相加恒为 0。早前版本用 (1,0)(0,1) 举例说 GDPO 能救,是错的。
  • 真正被救回来的是权重不等、或分量多于两个且各自方差不同的情形——此时标准化后的合并结果不再互相抵消。
  • 一个分量在组内塌缩、另一个仍有变化时,GDPO 让后者独自贡献梯度(tests/algorithms/test_gdpo.py::test_collapsed_component_contributes_zero_but_others_keep_signal)。注意这一类里 GRPO 的总和其实是变化的,所以它不是「GRPO 归零而 GDPO 不归零」的例子——早前版本把这条测试当作上一条的佐证引用,是引错了。
  • 所有分量都在组内恒定时,GDPO 与 GRPO 一样返回零,不会无中生有。

实现侧对这条边界是知情的:group_carries_reward_signalrelax/algorithms/rewards.py)判断一组是否还携带信号时,算的是这个算法真正会得到的合并 advantage,而不是「有没有任一分量变化」,其 docstring 明确记录了「两个分量标准化后互为相反数会抵消」这一情形。

GDPO(arXiv 2601.05242)要求训练链路同时具备:

  1. mapping 形式的多奖励输入;
  2. 按 reward 维度独立计算 prompt-group 组内统计;
  3. 对合并后的 sequence-level advantage 做完整 batch 的 sample-level 白化;
  4. 在本地 Component 路径与分布式 Megatron 路径上口径完全一致;
  5. 对缺失 / 非数值 / NaN·Inf / 零方差输入给出确定行为(fail-fast 或显式置零,绝不静默填补)。

3. 目标与非目标

3.1 目标

  • 算法名称、能力标志、三段实现标识符由注册表单点管理。
  • CLI choices、Controller 服务拓扑、训练分发全部从注册表派生。
  • 迁移 GRPO / GSPO / SAPO / CISPO / REINFORCE++ / REINFORCE++-baseline / PPO,不改任何数值行为(epsilon、默认超参、reduction 一律不动)。
  • 通过注册项接入 GDPO,通用路径 GDPO 名称判断;--advantage-estimator gdpo 正常可用。
  • GDPO 支持 ≥2 个可配置 reward key,权重可选。
  • 本地与 DP 分布式的 batch 白化使用同一 sample statistics 定义。
  • 每一条官方验收项都能追溯到具体测试文件与断言。

3.2 非目标

  • 不重写 Controller / Ray Serve / TransferQueue 架构。
  • 不新增依赖、service、worker 或训练传输字段。
  • 不修改既有算法的公式、epsilon、默认超参与 policy reduction。
  • 不恢复已禁用的 PPO 训练能力;PPO 仅作为「显式注册但禁用」的兼容项保留。
  • 不复现 GDPO 论文的训练指标,也不在本 RFC 中声称模型质量收益。
  • 不把注册表做成通用插件加载系统;内置算法用显式 dict 字面量注册(理由见 §5.3)。

4. 设计不变量

实现与后续扩展必须守住:

  1. 单一事实源:算法名称与实现映射只在 ALGORITHM_SPECS 注册一次。
  2. RL 训练分发路径无算法名:reward / advantage / ratio / objective 的公共调用方只查 spec 或能力标志,不写算法名分支。(例外如实标注:process_role 仍有 loss_type == "sft" 特判、ALGOS["sft"] 为手写字面项——SFT 由 loss_type 而非 --advantage-estimator 选择,属另一套;rollout 侧另有个别 if estimator == "ppo" 残留,不影响 GDPO 主路径,清理列入计划。)
  3. 统计边界明确:组内统计只覆盖同一 prompt group;batch 统计覆盖 sequence sample(每序列一个标量),不是 response token。
  4. 完整输入优先:需要完整 group 或完整 batch 的算法,不允许对局部数据近似计算。
  5. 失败显式:缺失或非法 reward 不得用 nan_to_num 之类手段静默修复。
  6. 历史行为锁定:迁移前已存在的公式用 characterization test 逐位固定。
  7. 轻量注册:注册表加载不得 import megatron / ray / transfer_queue / tensordict / components / backends(否则 --help 和 CPU 测试都会被拖入整个训练栈)。

5. 总体架构与注册表

5.1 数据流

flowchart LR
    CFG["--advantage-estimator / config"] --> SPEC["AlgorithmSpec (ALGORITHM_SPECS)"]
    SPEC --> CAP["能力标志<br/>needs_critic / supports_fully_async<br/>uses_reward_components / min_group_size ..."]
    SPEC --> RN["reward_normalizer<br/>(rollout 侧, CPU)"]
    SPEC --> AF["advantage_fn"]
    SPEC --> PL["policy_loss_fn"]

    CAP --> ARGS["arguments.py 校验"]
    CAP --> REG["core/registry.py 服务拓扑"]
    RN --> UTILS["utils.post_process_rewards()"]
    UTILS --> TQ["既有 rewards / raw_reward 字段"]
    TQ --> AF
    AF --> DISP["compute_advantages_and_returns()<br/>(两条执行路径共用)"]
    PL --> LOSS["megatron/loss.py<br/>policy_loss_function()"]
Loading

--advantage-estimator gdpo 只出现在注册条目和配置里;reward / advantage / loss / Controller 的公共路径不出现 GDPO 名称。

5.2 AlgorithmSpec:字段存字符串标识符,不存 callable

@dataclass(frozen=True)
class AlgorithmSpec:
    name: str
    reward_normalizer: str        # → REWARD_NORMALIZERS  (rollout 侧)
    advantage_fn: str             # → ADVANTAGE_FNS
    policy_loss_fn: str           # → POLICY_LOSS_FNS
    kl_level: str = "token"       # GSPO 用 "sequence"
    needs_full_log_probs: bool = False
    needs_critic: bool = False
    requires_normalize_advantages: bool = False
    forbids_normalize_advantages: bool = False
    requires_rewards_normalization: bool = False
    supports_fully_async: bool = True
    uses_reward_components: bool = False
    min_group_size: int = 1
    allows_custom_reward_post_process: bool = True
    disabled_reason: str | None = None

为什么存字符串而非 callable:advantage 公式跑在 Ray Serve 的 Advantages deployment 里,policy loss 跑在 Megatron worker 里——两个进程 import 的模块子集不同。若字段直接持有函数对象,注册模块就必须在两个进程都能 import 到所有实现,等于把整个训练栈拖进参数解析。改存标识符后,每个进程各自把标识符解析到自己表里的实现,注册模块只依赖标准库,这正是它能在 CPU-only runner 上被测试的前提(见不变量 7)。

#214SymbolRef 对比(如实说明,避免夸大):#214 也存字符串(点分路径 + importlib 延迟解析),二者都避免了持有 callable。真实差异是:本实现用每进程本地的实现表ADVANTAGE_FNS / POLICY_LOSS_FNS / REWARD_NORMALIZERS),标识符解析发生在进程内、启动期就由 arguments.py 校验能否解析(见 §5.4);#214importlib 全局解析,解析错误暴露稍晚。两种都成立,本实现更简单、故障面更小。注意本实现的「单一事实源」指的是算法映射(名称→三段实现)只在 ALGORITHM_SPECS 声明一次,并不意味着每个符号只存在一处。

5.3 为什么用显式 dict 字面量而非装饰器注册

装饰器式 @register 依赖「这个模块被 import 过没有」。由于 advantage 与 policy loss 在两个 import 图不同的进程里执行,装饰器会在某一侧漏 import 时静默丢掉一个算法——正是本任务要根治的病。因此 ALGORITHM_SPECS 是一个显式 dict 字面量,静态可读、不依赖 import 副作用。

5.4 能力标志如何驱动编排

  • needs_critic → 设置 args.use_criticarguments.py:2509),供 actor 的 NCCL/offload 逻辑消费;注意:当前 role 拓扑 _standard_rl_roles() 对所有 RL 算法给同一角色集、critic 的实际启用还依赖 mode 与该 flag,唯一 needs_critic=True 的 PPO 已禁用,所以这条目前是「已接线但无运行中的消费者」,不是 spec 直接生成 critic 服务;
  • supports_fully_async--fully-async 是否放行;
  • requires_normalize_advantages / forbids_normalize_advantages--normalize-advantages 的强制/禁止;
  • requires_rewards_normalizationuses_reward_components → 驱动 --gdpo-reward-keys 校验;
  • min_group_size--n-samples-per-prompt 下限;
  • allows_custom_reward_post_process → 是否允许 --custom-reward-post-process-path

core/registry.py 里的 ALGOS 不再手写,改为 {name: 标准 RL 角色集 for name in list_algorithm_names()},从注册表派生。这一步顺带修好了 reinforce_plus_plus 系列「实现齐全却因 ALGOS 漏项而崩」的老 bug(tests/algorithms/test_algos_roles.py 覆盖)。


6. 现有算法迁移与数值等价性

七个算法迁到注册表,只改「实现如何被发现和分发」,不碰公式。等价性由两类测试锁死:

  • characterization testtest_reward_normalizers.py):内嵌一份 main@039ce87post_process_rewards 逐位冻结拷贝,重构后任何一个 float bit 不同就 fail
  • dispatch parity testtest_dispatch_parity_vs_main.py):因为 relax/utils/training/ppo_utils.py 在本分支与 main 逐字节相同,每个 estimator/loss 仍调用同一个函数对象——所以这里验证的是「注册表把每个算法路由到与 main 的 if/elif 完全相同的目的地」,而非重算数值(后者按构造必然通过、证明不了东西)。

关键常量 STD_EPS = 1e-6 被显式冻结并注释「不可移动」——GRPO/GSPO/SAPO/CISPO 的组内标准化继续用 sample std(correction=1)+ 1e-6,等价性测试就靠它。


7. GDPO 实现

7.1 三步,分别落在哪个进程

步骤 内容 执行位置 代码
步骤 1 每个 reward key 各自做 prompt-group 组内标准化 rollout 侧(CPU) algorithms/rewards.pyreward_normalizer="gdpo_decoupled"
步骤 2 --gdpo-reward-weights 加权合并成每样本一个 A_sum rollout 侧(CPU) 同上
步骤 3 对整个训练 batch 做 sample-level 白化,再广播到 token advantage 阶段 algorithms/advantages.py:advantage_gdpo

为什么步骤 3 放在 advantage 阶段而非 reward 侧:这样它就落在 --custom-reward-post-process-path(会整段短路 reward 后处理)够不到的地方,也避开了 streaming transfer-batch 边界那些尾批过小的问题。

7.2 数值正确性(本实现真正下功夫、也最能拉开差距的地方)

  • GDPO_EPS = 1e-4STD_EPS = 1e-6:GDPO 两步标准化都除以 std + 1e-4对齐 NVLabs GDPO 的 TRL 工程实现trl-GDPO/.../grpo_trainer.pyscale_rewards 路径)——这是工程口径,不是论文规定(论文 Eq.4 未给 group-level epsilon,NVLabs 的 VERL 路径 eps 取值也不同)。GDPO 是新算法、没有需要保持的旧 Relax 行为,故取 TRL 口径。差异只在近退化组显现:某组 std ~1e-3 时两个 eps 对尺度因子差约 10%(test_gdpo.py 有对拍)。
  • step3 的 batch 统计走 float64 两遍:一遍式 E[x²]−E[x]² 在值远离 0 时做两个近似大数相减、被舍入主导——对 [1000, 1000.01, 1000.02, 1000.03] 直接返回方差 0(真值 std=1.29e-2),且无声地把整 batch advantage 归零。所以 distributed_mean_std 全程 .double()、中心化后再平方。范围如实说明:这是 Relax 自加的数值加固,只覆盖 step3 的批统计;step1/2 的组内标准化(rewards.py)在 float32 上用 torch.std(稳定实现,对本例够用)。若未来 reward 分量本身量级上千(如原始 token-length reward),step1 也应升 float64——这条列入计划(见 §11)。
  • 精确相等判 collapseis_collapsedmin == max 而非「std < 容差」。任何足够大到能抓住浮点残差的相对容差,都会把 [10000, 10000.005, ...] 这种完全有效的 batch 误杀;精确相等对已量化到 dtype 的值无假阳性(注:值先降到 float32,float32 下已不可区分的输入会被判为 collapse,这是 dtype 精度的固有边界,非本函数引入)。collapse 组返回精确零。(is_collapsed docstring 里「近相等由 STD_EPS 阻尼」应改为 GDPO_EPS——见 §11 的措辞修订项。)
  • 按段白化 + 跨 DP 对齐(本实现相对 【Task.27】解耦算法配置接入 GDPO -RFC #214 的核心差异,见 §7.4)advantage_gdpomini_batch_sizes(由 loss.py:589ROLLOUT_MINI_LOCAL_SAMPLE_COUNTS_KEY 传入)逐 optimizer 训练批分段白化;段数来自 minibatch plan(非数据),每个 rank 的 per-segment 集合通信调用次数一致、正常路径不会 hang。分布式下 distributed_mean_std 跨 DP all-reduce,统计量覆盖全部 rank 而非单 rank 分片;空分片用 -inf(MAX 单位元)参与 collective。测试覆盖如实说明test_distributed_whitening.py 真起 2 进程 gloo,但目前验证的是底层 whiten_scalar(单段),尚未覆盖 _whiten_by_segment 的多段组合与「单 rank 元数据损坏时全 rank 一致失败」——两者列入计划(见 §11)。

7.3 GDPO 的能力标志——每一条都对应一个真实失效场景

"gdpo": AlgorithmSpec(
    name="gdpo",
    reward_normalizer="gdpo_decoupled",
    advantage_fn="gdpo",
    policy_loss_fn="ppo_clip",
    forbids_normalize_advantages=True,     # 步骤3已白化, --normalize-advantages 会二次 token 级白化
    requires_rewards_normalization=True,
    supports_fully_async=False,            # 该模式每次只见 batch 的一个切片, 切片=1 时 advantage 恒 0
    uses_reward_components=True,           # 驱动 --gdpo-reward-keys 校验
    min_group_size=2,                      # 步骤1除以无偏组内 std, 单样本无定义
    allows_custom_reward_post_process=False, # 该 hook 会短路后处理, 静默跳过步骤1/2
),

关于 supports_fully_async=False——这是当前 Relax 执行拓扑的工程边界,不是 GDPO 的数学限制--fully-async 把 advantage 计算路由到单副本 Advantages deployment,它没有 DP 组、每次只拿 global_batch_size / num_iters_per_train_update 一个切片;依赖 batch 级统计的算法会在切片而非整 batch 上算,切片为 1 时毫无信号且静默收敛退出。而且切片大小还取决于 controller 装的哪个 TransferQueue sampler(--balance-data 下是 per-DP-rank 的 SeqlenBalancedSampler),所以「切片恰好等于 batch 就放行」不成立。因此本实现选择 fail-closed(校验阶段拒绝),先保证正确性。

#214 的分歧(如实标注):#214 选择让 Advantages 组件请求并等待完整 global_batch_size 后再算,从而支持 fully-async。该路径若实现正确是可行的,可作为后续 PR;本 RFC 不把尚未在 Controller 行为上证实的 async 路径写进保证,宁可先禁用。(--hybrid 不受影响:它用 colocate 角色集,advantage 在有 DP 组的 Megatron worker 里算。)

7.4 batch 归一化边界:本实现自动分段,无需 #214 的硬约束

Eq.6 要求 step3 的白化按训练 batch(一个 optimizer step 的样本)进行。调用方为了效率会先合并 num_rollout_minis 个训练批再调用 advantage;关键在于如何在合并后仍还原出「每个训练批」的边界

  • 本实现_whiten_by_segmentmini_batch_sizes 把合并样本切回各训练批、每段独立白化。因此 num_rollout_minis > 1 时依然逐 optimizer batch 对齐 Eq.6,不要求 rollout_batch_size × n_samples_per_prompt == global_batch_size
  • 【Task.27】解耦算法配置接入 GDPO -RFC #214:用 validator 强制上式相等(即强制 num_rollout_minis == 1),让「一次 rollout window == 一个训练 batch」,从而回避多段问题。

两者在 #214 的约束下行为一致;但本实现不需要那条约束、配置更灵活。

mini_batch_sizes=None 的行为要更正:早前版本写它「自动退化为单段」,实际实现是 fail-fast——_whiten_by_segmentValueError,指出调用方没有提供 rollout_mini_local_sample_counts。这是有意的:静默按单段处理会在 num_rollout_minis > 1 时跨训练批白化,而这恰恰是本节要避免的失效模式,让它无声发生比报错危险。tests/algorithms/test_gdpo_loss_wiring.py::test_a_missing_segmentation_fails_instead_of_defaulting 钉住了这一点。docs 里还记录了「错误地跨多批合并白化会导致 advantage 符号翻转」的实测,正是这段设计要避免的。


8. 参数与配置

--advantage-estimator gdpo
--gdpo-reward-keys correctness format     # 独立归一化的分量, 至少两个
--gdpo-reward-weights 1.0 1.0             # 可省, 默认全 1; 乘的是归一化后的 advantage
--custom-rm-path examples.gdpo.reward_gdpo.reward_func
--reward-key score                        # 必填: metrics 与 raw_reward 用的标量
--n-samples-per-prompt 8                  # 必须 ≥ min_group_size(=2)

冲突项在参数校验阶段 fail-fast--normalize-advantages(二次白化)、--custom-reward-post-process-path(短路前两步)、--fully-async(切片过小)与 GDPO 同用直接报错;--gdpo-reward-keys 空、含重复、或 weights 长度不匹配也报错。--dynamic-sampling-filter-path 只警告(自定义过滤器的语义本 RFC 无法预知)。内置过滤器已不再按单标量判组:早前版本称它按 --reward-key 判断、可能丢掉只在其它分量有信号的组;现在 group_carries_reward_signal 会跑完本算法的前两步、按真正的合并 advantage 是否在 float32 下变化来判定,零权重静默掉的分量和互相抵消的分量都算在内。


9. 测试与验收可追溯性

tests/algorithms/pr2/gdpo@cb7d181 实测 809 passed(Python 3.12.12 + torch 2.13.0 CPU)。

这个数字会随每次提交移动,所以它钉的是一个 SHA,不是一个承诺——早前版本写的 690 / 11 文件 / 214 函数是 plan 阶段的快照,已经过期。下表括号里的数字同样按该 SHA 重数。以 PR #276 / #277 的 CI 为准;本节只说明覆盖面怎么分布。

官方验收项 测试文件 要点
算法名称/能力/实现通过注册表管理 test_algorithm_registry.py(17) spec 字段、标识符解析、get_algorithm/list_algorithm_names
不再重复维护算法名单与 if/elif 链 test_arguments_spec_driven.py(58)、test_algos_roles.py(14) CLI choices / ALGOS 均从注册表派生;补齐 REINFORCE++ 漏项
现有算法数值等价 test_reward_normalizers.py(398)、test_dispatch_parity_vs_main.py(97)、test_advantage_estimators.py(40) 逐位 characterization + 路由 parity + golden value
--advantage-estimator gdpo 可用 + GDPO 公式 test_gdpo.py(100) 独立 Python 参考实现(Eq.4/Eq.7)对拍
GDPO ≥2 reward key test_example_reward_gdpo.py(20)、test_gdpo.py 两分量、权重、缺失/非数值/NaN·Inf fail-fast
GDPO 通过注册接入, 无 if=="gdpo" test_policy_loss_dispatch.py(17)、test_post_process_rewards_dispatch.py(12) 分发全走注册表
reward-collapse / 零方差 test_gdpo.pytest_reward_normalizers.py collapse 精确置零;单分量塌缩另一分量仍有信号
分布式 batch 白化正确 test_distributed_whitening.py(10) 真起 2 进程 gloo 组, 验证跨分片统计与空分片

(该 SHA 上 tests/algorithms/ 共 13 个 test 文件、参数化后 809 个用例;test_gdpo_loss_wiring.py(4)与 test_multi_reward_consumers.py(22)是 plan 之后新增的,不在上表的验收行里。)

说明:上述 809 passed 与 pre-commit run 全过均为本机实测(Python 3.12.12 + torch 2.13.0 CPU)。这个环境不装 Megatron,而项目镜像装——两者会走不同分支,test_gdpo_loss_wiring.py 曾因此在 CI 全绿、在镜像里 4 failed(已于 cb7d181 修正,并在两种配置下各验一遍)。未覆盖:完整 pytest tests/ 全量回归、GPU 端到端训练 smoke(plan 阶段不跑,列入 §11.2)。另:GDPO 的非有限值 guard 已在 2×H100、生产镜像(torch 2.11.0+cu129 / NCCL 2.28.9)上做过两进程 NCCL 冒烟。数值等价性中,reward normalizer 有逐位 characterization,advantage/policy 路径以「ppo_utils.py 与 main 逐字节相同 + 路由 parity」论证(见 §6)。


10. 交付物

对应题面「算法注册与分发模块 / 现有算法迁移 / GDPO 实现 / 单元测试 / correctness+format 最小训练示例 / 新算法接入文档」:

  • 注册与分发relax/algorithms/spec.py advantages.py rewards.py policy.py numerics.py __init__.py)+ relax/core/registry.py 重构。
  • 现有算法迁移components/advantages.pybackends/megatron/loss.pyutils/utils.pyutils/arguments.py 删除各自 if/elif,改查注册表。
  • GDPO 实现:三步归一化 + 全部边界处理(见 §7)。
  • 单元测试tests/algorithms/ 11 文件、690 用例。
  • 最小训练示例examples/gdpo/reward_gdpo.py 双分量 + 单卡 Qwen3-0.6B GSM8K 脚本 + 中文 README)。如实标注:reward 函数有单测(test_example_reward_gdpo.py),但脚本本身未跑 GPU 端到端(plan 阶段,见 §11.2)。
  • 接入文档docs/{zh,en}/guide/adding-an-algorithm.mddocs/{zh,en}/examples/algorithms.md

11. 已知边界、计划内工作与如实修订

11.1 已实现、无偏差的边界(澄清)

  • step3 的 batch 边界已由分段白化处理:见 §7.4——mini_batch_sizes 存在时逐 optimizer 训练批分段白化,num_rollout_minis > 1 也对齐 Eq.6,无需强制 rollout_batch_size × n_samples == global_batch_size。(早先版本的示例 README / 脚本 / advantages.py 注释里残留过「会静默跨多 optimizer step 归一化」的旧说明,与当前代码不符,本次一并清理。)
  • 单奖励时 GDPO ≠ GRPO:不是「差一个正标量」那么简单——step1 除以 std_g + 1e-4、GRPO 除以 std_g + 1e-6,各 group 的 std_g 不同 → 尺度因子逐组不同;step3 还会再做一次 batch 白化。要 GRPO 语义就直接用 --advantage-estimator grpo
  • --n-samples-per-prompt 2 幅度信息丢失:任意两个不同值标准化后恒为 ±0.7071,示例用 8 规避。

11.2 计划内工作(本 RFC 承诺、随入选后的 PR 落地)

以下项不影响 GDPO 训练主链的数学正确性,但为完整满足验收与对齐 #214,列为计划:

  1. multi-reward 下游消费者向量化(对应验收「reward-collapse」条款,也是相对 【Task.27】解耦算法配置接入 GDPO -RFC #214 的主要差距):当前 dynamic sampling filter 与 rollout 的 zero-std metrics 仍按单个 --reward-key 判组(dynamic_sampling_filters.pyagentic/rollout.pydistributed/ray/rollout.py),本 RFC 目前只在配 --dynamic-sampling-filter-path警告。计划用 uses_reward_components 能力标志驱动一个 reward-vector-aware 视图:任一配置分量在组内有变化即保留该组,并让 collapse metrics 按分量报告。具体失败场景:correctness 组内恒定、format 有变化时,内置 filter 会错误丢掉本应保留的组。
  2. 非有限数值的 fail-fast 收口extract_reward_components / 权重校验目前在转 float32 之前math.isfinite 检查;有限但超出 float32 表示范围的输入(如 1e300)会在 cast 后变 inf/nan,随后被 whiten_scalar 当作非有限 std 静默归零,与不变量 5「失败显式」不符。计划在 dtype cast 后、加权合并后各加一次 torch.isfinite 校验,并让 whiten_scalar 对非有限输入抛错而非归零。(实践中 reward/weight 取到 1e300 属异常配置,故列为加固而非阻断项。)
  3. step1/2 数值精度对齐:如 §7.2,组内标准化目前在 float32 上进行;若 reward 分量本身量级达上千,计划升至 float64,与 step3 口径一致。
  4. 分布式测试补全:为 _whiten_by_segment 增加多段、跨 rank 局部样本数不等的 2 进程 gloo 用例,并补「单 rank 元数据损坏 → 全 rank 在同一位置一致失败(而非 hang)」的用例。

11.3 措辞/注释修订(本稿与工作区代码一致,随 P0 修订一并提交)

  • is_collapsed docstring 中「近相等由 STD_EPS 阻尼」应为 GDPO_EPS(GDPO 白化实际用 GDPO_EPS)。
  • _PPO_DISABLED 的注释「Formatted lazily by get_algorithm」应更正为「由 arguments.py 的算法校验在 raise.format(...)」(格式化确实发生,仅注释指错了位置)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions