字段
内容
RFC 状态
Draft —— 已有完整参考实现 + 全套 CPU 测试,待维护者设计评审
对应任务
Relax 贡献者计划 2026 · 第一期 · 高级任务 27
上游仓库
redai-infra/Relax
开发 fork
Men1scus/Relax
基线 commit
039ce876d25540adad847d4223b4de4722d8f425(main,与 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.reward、post_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.py 的 ALGOS 手写算法→组件映射
parser 接受但服务拓扑缺失 → 启动崩溃
Reward
relax/utils/utils.py:post_process_rewards() 维护算法名单 + 归一化分支
reward 公式与算法定义分离
Advantage
relax/components/advantages.py 与 relax/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_signal(relax/algorithms/rewards.py)判断一组是否还携带信号时,算的是这个算法真正会得到的合并 advantage ,而不是「有没有任一分量变化」,其 docstring 明确记录了「两个分量标准化后互为相反数会抵消」这一情形。
GDPO(arXiv 2601.05242 )要求训练链路同时具备:
mapping 形式的多奖励输入;
按 reward 维度独立 计算 prompt-group 组内统计;
对合并后的 sequence-level advantage 做完整 batch 的 sample-level 白化;
在本地 Component 路径与分布式 Megatron 路径上口径完全一致;
对缺失 / 非数值 / 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. 设计不变量
实现与后续扩展必须守住:
单一事实源 :算法名称与实现映射只在 ALGORITHM_SPECS 注册一次。
RL 训练分发路径无算法名 :reward / advantage / ratio / objective 的公共调用方只查 spec 或能力标志,不写算法名分支。(例外如实标注:process_role 仍有 loss_type == "sft" 特判、ALGOS["sft"] 为手写字面项——SFT 由 loss_type 而非 --advantage-estimator 选择,属另一套;rollout 侧另有个别 if estimator == "ppo" 残留,不影响 GDPO 主路径,清理列入计划。)
统计边界明确 :组内统计只覆盖同一 prompt group;batch 统计覆盖 sequence sample(每序列一个标量),不是 response token。
完整输入优先 :需要完整 group 或完整 batch 的算法,不允许对局部数据近似计算。
失败显式 :缺失或非法 reward 不得用 nan_to_num 之类手段静默修复。
历史行为锁定 :迁移前已存在的公式用 characterization test 逐位固定。
轻量注册 :注册表加载不得 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)。
与 #214 的 SymbolRef 对比(如实说明,避免夸大):#214 也存字符串(点分路径 + importlib 延迟解析),二者都避免了持有 callable。真实差异是:本实现用每进程本地的实现表 (ADVANTAGE_FNS / POLICY_LOSS_FNS / REWARD_NORMALIZERS),标识符解析发生在进程内、启动期就由 arguments.py 校验能否解析(见 §5.4);#214 用 importlib 全局解析,解析错误暴露稍晚。两种都成立,本实现更简单、故障面更小。注意本实现的「单一事实源」指的是算法映射 (名称→三段实现)只在 ALGORITHM_SPECS 声明一次,并不意味着每个符号只存在一处。
5.3 为什么用显式 dict 字面量 而非装饰器注册
装饰器式 @register 依赖「这个模块被 import 过没有」。由于 advantage 与 policy loss 在两个 import 图不同的进程里执行,装饰器会在某一侧漏 import 时静默丢掉一个算法 ——正是本任务要根治的病。因此 ALGORITHM_SPECS 是一个显式 dict 字面量,静态可读、不依赖 import 副作用。
5.4 能力标志如何驱动编排
needs_critic → 设置 args.use_critic(arguments.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_normalization、uses_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 test (test_reward_normalizers.py):内嵌一份 main@039ce87 的 post_process_rewards 逐位冻结拷贝,重构后任何一个 float bit 不同就 fail 。
dispatch parity test (test_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.py(reward_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-4 ≠ STD_EPS = 1e-6 :GDPO 两步标准化都除以 std + 1e-4,对齐 NVLabs GDPO 的 TRL 工程实现 (trl-GDPO/.../grpo_trainer.py 的 scale_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)。
精确相等判 collapse :is_collapsed 用 min == 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_gdpo 用 mini_batch_sizes(由 loss.py:589 从 ROLLOUT_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_segment 按 mini_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_segment 抛 ValueError,指出调用方没有提供 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.py、test_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.py、backends/megatron/loss.py、utils/utils.py、utils/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.md、docs/{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 ,列为计划:
multi-reward 下游消费者向量化 (对应验收「reward-collapse」条款,也是相对 【Task.27】解耦算法配置接入 GDPO -RFC #214 的主要差距):当前 dynamic sampling filter 与 rollout 的 zero-std metrics 仍按单个 --reward-key 判组(dynamic_sampling_filters.py、agentic/rollout.py、distributed/ray/rollout.py),本 RFC 目前只在配 --dynamic-sampling-filter-path 时警告 。计划用 uses_reward_components 能力标志驱动一个 reward-vector-aware 视图:任一配置分量在组内有变化即保留该组 ,并让 collapse metrics 按分量报告。具体失败场景:correctness 组内恒定、format 有变化时,内置 filter 会错误丢掉本应保留的组。
非有限数值的 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 属异常配置,故列为加固而非阻断项。)
step1/2 数值精度对齐 :如 §7.2,组内标准化目前在 float32 上进行;若 reward 分量本身量级达上千,计划升至 float64,与 step3 口径一致。
分布式测试补全 :为 _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(...)」(格式化确实发生,仅注释指错了位置)。
redai-infra/RelaxMen1scus/Relax039ce876d25540adad847d4223b4de4722d8f425(main,与 RFC #214/#105 同一基线,可直接对比)b7cb881(分支feat/algorithm-registry-gdpo,24 次提交;末次为本轮评审后的文档/注释修订)tests/algorithms/本机实测 690 passed(含 2 进程 gloo 分布式白化);pre-commit run --all-files全部通过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.reward、post_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上,同一份算法知识散落在这些位置:--advantage-estimator手工维护choices列表relax/core/registry.py的ALGOS手写算法→组件映射relax/utils/utils.py:post_process_rewards()维护算法名单 + 归一化分支relax/components/advantages.py与relax/backends/megatron/loss.py各维护一份if/elifarguments.py两轮校验各自硬编码算法名问题的本质不是字符串本身,而是「同一个决策被多个模块各自拥有一份副本」。只加一个新的 dispatch helper 并不能消除漏接——只要 CLI、能力判断、服务视图还各有各的名单,遗漏就还会发生。
2.2 GDPO 带来的新约束
标量 GRPO 是「先把多个 reward 加起来、再做一次组内归一化」,于是组内总奖励恒定的一组 rollout 会被整组归零,无论各分量内部有没有差异。GDPO 对每个分量独立做组内标准化再合并,让每个分量的信号各自存活到合并那一步。
这里要说清一个边界,而不是把它讲成万灵药——本 RFC 早前的版本正是在这里讲错了:
format = C − correctness,标准化后两列精确互为相反数,等权相加恒为 0。早前版本用(1,0)与(0,1)举例说 GDPO 能救,是错的。tests/algorithms/test_gdpo.py::test_collapsed_component_contributes_zero_but_others_keep_signal)。注意这一类里 GRPO 的总和其实也是变化的,所以它不是「GRPO 归零而 GDPO 不归零」的例子——早前版本把这条测试当作上一条的佐证引用,是引错了。实现侧对这条边界是知情的:
group_carries_reward_signal(relax/algorithms/rewards.py)判断一组是否还携带信号时,算的是这个算法真正会得到的合并 advantage,而不是「有没有任一分量变化」,其 docstring 明确记录了「两个分量标准化后互为相反数会抵消」这一情形。GDPO(arXiv 2601.05242)要求训练链路同时具备:
3. 目标与非目标
3.1 目标
--advantage-estimator gdpo正常可用。3.2 非目标
4. 设计不变量
实现与后续扩展必须守住:
ALGORITHM_SPECS注册一次。process_role仍有loss_type == "sft"特判、ALGOS["sft"]为手写字面项——SFT 由loss_type而非--advantage-estimator选择,属另一套;rollout 侧另有个别if estimator == "ppo"残留,不影响 GDPO 主路径,清理列入计划。)nan_to_num之类手段静默修复。--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()"]--advantage-estimator gdpo只出现在注册条目和配置里;reward / advantage / loss / Controller 的公共路径不出现 GDPO 名称。5.2
AlgorithmSpec:字段存字符串标识符,不存 callable为什么存字符串而非 callable:advantage 公式跑在 Ray Serve 的
Advantagesdeployment 里,policy loss 跑在 Megatron worker 里——两个进程 import 的模块子集不同。若字段直接持有函数对象,注册模块就必须在两个进程都能 import 到所有实现,等于把整个训练栈拖进参数解析。改存标识符后,每个进程各自把标识符解析到自己表里的实现,注册模块只依赖标准库,这正是它能在 CPU-only runner 上被测试的前提(见不变量 7)。5.3 为什么用显式 dict 字面量而非装饰器注册
装饰器式
@register依赖「这个模块被 import 过没有」。由于 advantage 与 policy loss 在两个 import 图不同的进程里执行,装饰器会在某一侧漏 import 时静默丢掉一个算法——正是本任务要根治的病。因此ALGORITHM_SPECS是一个显式 dict 字面量,静态可读、不依赖 import 副作用。5.4 能力标志如何驱动编排
needs_critic→ 设置args.use_critic(arguments.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_normalization、uses_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. 现有算法迁移与数值等价性
七个算法迁到注册表,只改「实现如何被发现和分发」,不碰公式。等价性由两类测试锁死:
test_reward_normalizers.py):内嵌一份main@039ce87的post_process_rewards逐位冻结拷贝,重构后任何一个 float bit 不同就 fail。test_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 三步,分别落在哪个进程
algorithms/rewards.py(reward_normalizer="gdpo_decoupled")--gdpo-reward-weights加权合并成每样本一个A_sumalgorithms/advantages.py:advantage_gdpo为什么步骤 3 放在 advantage 阶段而非 reward 侧:这样它就落在
--custom-reward-post-process-path(会整段短路 reward 后处理)够不到的地方,也避开了 streaming transfer-batch 边界那些尾批过小的问题。7.2 数值正确性(本实现真正下功夫、也最能拉开差距的地方)
GDPO_EPS = 1e-4≠STD_EPS = 1e-6:GDPO 两步标准化都除以std + 1e-4,对齐 NVLabs GDPO 的 TRL 工程实现(trl-GDPO/.../grpo_trainer.py的scale_rewards路径)——这是工程口径,不是论文规定(论文 Eq.4 未给 group-level epsilon,NVLabs 的 VERL 路径 eps 取值也不同)。GDPO 是新算法、没有需要保持的旧 Relax 行为,故取 TRL 口径。差异只在近退化组显现:某组 std ~1e-3 时两个 eps 对尺度因子差约 10%(test_gdpo.py有对拍)。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)。is_collapsed用min == max而非「std < 容差」。任何足够大到能抓住浮点残差的相对容差,都会把[10000, 10000.005, ...]这种完全有效的 batch 误杀;精确相等对已量化到 dtype 的值无假阳性(注:值先降到 float32,float32 下已不可区分的输入会被判为 collapse,这是 dtype 精度的固有边界,非本函数引入)。collapse 组返回精确零。(is_collapseddocstring 里「近相等由 STD_EPS 阻尼」应改为GDPO_EPS——见 §11 的措辞修订项。)advantage_gdpo用mini_batch_sizes(由loss.py:589从ROLLOUT_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 的能力标志——每一条都对应一个真实失效场景
关于
supports_fully_async=False——这是当前 Relax 执行拓扑的工程边界,不是 GDPO 的数学限制。--fully-async把 advantage 计算路由到单副本Advantagesdeployment,它没有 DP 组、每次只拿global_batch_size / num_iters_per_train_update一个切片;依赖 batch 级统计的算法会在切片而非整 batch 上算,切片为 1 时毫无信号且静默收敛退出。而且切片大小还取决于 controller 装的哪个 TransferQueue sampler(--balance-data下是 per-DP-rank 的SeqlenBalancedSampler),所以「切片恰好等于 batch 就放行」不成立。因此本实现选择 fail-closed(校验阶段拒绝),先保证正确性。7.4 batch 归一化边界:本实现自动分段,无需 #214 的硬约束
Eq.6 要求 step3 的白化按训练 batch(一个 optimizer step 的样本)进行。调用方为了效率会先合并
num_rollout_minis个训练批再调用 advantage;关键在于如何在合并后仍还原出「每个训练批」的边界:_whiten_by_segment按mini_batch_sizes把合并样本切回各训练批、每段独立白化。因此num_rollout_minis > 1时依然逐 optimizer batch 对齐 Eq.6,不要求rollout_batch_size × n_samples_per_prompt == global_batch_size。num_rollout_minis == 1),让「一次 rollout window == 一个训练 batch」,从而回避多段问题。两者在 #214 的约束下行为一致;但本实现不需要那条约束、配置更灵活。
mini_batch_sizes=None的行为要更正:早前版本写它「自动退化为单段」,实际实现是 fail-fast——_whiten_by_segment抛ValueError,指出调用方没有提供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. 参数与配置
冲突项在参数校验阶段 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)。test_algorithm_registry.py(17)get_algorithm/list_algorithm_namestest_arguments_spec_driven.py(58)、test_algos_roles.py(14)ALGOS均从注册表派生;补齐 REINFORCE++ 漏项test_reward_normalizers.py(398)、test_dispatch_parity_vs_main.py(97)、test_advantage_estimators.py(40)--advantage-estimator gdpo可用 + GDPO 公式test_gdpo.py(100)test_example_reward_gdpo.py(20)、test_gdpo.pyif=="gdpo"test_policy_loss_dispatch.py(17)、test_post_process_rewards_dispatch.py(12)test_gdpo.py、test_reward_normalizers.pytest_distributed_whitening.py(10)(该 SHA 上
tests/algorithms/共 13 个 test 文件、参数化后 809 个用例;test_gdpo_loss_wiring.py(4)与test_multi_reward_consumers.py(22)是 plan 之后新增的,不在上表的验收行里。)10. 交付物
对应题面「算法注册与分发模块 / 现有算法迁移 / GDPO 实现 / 单元测试 / correctness+format 最小训练示例 / 新算法接入文档」:
relax/algorithms/(spec.pyadvantages.pyrewards.pypolicy.pynumerics.py__init__.py)+relax/core/registry.py重构。components/advantages.py、backends/megatron/loss.py、utils/utils.py、utils/arguments.py删除各自 if/elif,改查注册表。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.md、docs/{zh,en}/examples/algorithms.md。11. 已知边界、计划内工作与如实修订
11.1 已实现、无偏差的边界(澄清)
mini_batch_sizes存在时逐 optimizer 训练批分段白化,num_rollout_minis > 1也对齐 Eq.6,无需强制rollout_batch_size × n_samples == global_batch_size。(早先版本的示例 README / 脚本 /advantages.py注释里残留过「会静默跨多 optimizer step 归一化」的旧说明,与当前代码不符,本次一并清理。)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,列为计划:
--reward-key判组(dynamic_sampling_filters.py、agentic/rollout.py、distributed/ray/rollout.py),本 RFC 目前只在配--dynamic-sampling-filter-path时警告。计划用uses_reward_components能力标志驱动一个 reward-vector-aware 视图:任一配置分量在组内有变化即保留该组,并让 collapse metrics 按分量报告。具体失败场景:correctness组内恒定、format有变化时,内置 filter 会错误丢掉本应保留的组。extract_reward_components/ 权重校验目前在转 float32 之前用math.isfinite检查;有限但超出 float32 表示范围的输入(如1e300)会在 cast 后变inf/nan,随后被whiten_scalar当作非有限 std 静默归零,与不变量 5「失败显式」不符。计划在 dtype cast 后、加权合并后各加一次torch.isfinite校验,并让whiten_scalar对非有限输入抛错而非归零。(实践中 reward/weight 取到 1e300 属异常配置,故列为加固而非阻断项。)_whiten_by_segment增加多段、跨 rank 局部样本数不等的 2 进程 gloo 用例,并补「单 rank 元数据损坏 → 全 rank 在同一位置一致失败(而非 hang)」的用例。11.3 措辞/注释修订(本稿与工作区代码一致,随 P0 修订一并提交)
is_collapseddocstring 中「近相等由STD_EPS阻尼」应为GDPO_EPS(GDPO 白化实际用GDPO_EPS)。_PPO_DISABLED的注释「Formatted lazily byget_algorithm」应更正为「由arguments.py的算法校验在raise前.format(...)」(格式化确实发生,仅注释指错了位置)。