Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export default defineConfig({
{ text: 'Installation', link: '/en/guide/installation' },
{ text: 'Quick Start', link: '/en/guide/quick-start' },
{ text: 'Customize Training', link: '/en/guide/customize-training' },
{ text: 'Adding an Algorithm', link: '/en/guide/adding-an-algorithm' },
{ text: 'SFT Training', link: '/en/guide/sft-training' },
{ text: 'PPO Training', link: '/en/guide/ppo-training' },
{ text: 'REINFORCE++', link: '/en/guide/reinforce-plus-plus' },
Expand Down Expand Up @@ -355,6 +356,7 @@ export default defineConfig({
{ text: '安装', link: '/zh/guide/installation' },
{ text: '快速上手', link: '/zh/guide/quick-start' },
{ text: '自定义训练', link: '/zh/guide/customize-training' },
{ text: '接入新算法', link: '/zh/guide/adding-an-algorithm' },
{ text: 'SFT 训练', link: '/zh/guide/sft-training' },
{ text: 'PPO 训练', link: '/zh/guide/ppo-training' },
{ text: 'REINFORCE++', link: '/zh/guide/reinforce-plus-plus' },
Expand Down
177 changes: 177 additions & 0 deletions docs/en/guide/adding-an-algorithm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Adding an Algorithm

Algorithms plug into Relax through the registry under `relax/algorithms/`. An
algorithm name is no longer scattered across `if/elif` chains — it is described
by one `AlgorithmSpec`, and each stage looks up what it needs.

## Registry Layout

```
relax/algorithms/
├── spec.py AlgorithmSpec definition + the ALGORITHM_SPECS registry
├── rewards.py reward normalization strategies + REWARD_NORMALIZERS
├── advantages.py advantage estimators + ADVANTAGE_FNS
└── policy.py policy loss adapters + POLICY_LOSS_FNS
```

Three hard constraints:

1. **No heavy top-level imports under `relax/algorithms/`** — not `megatron`,
`ray`, `transfer_queue`, `tensordict`, `relax.components` or
`relax.backends`. The registry is imported by argument parsing and by both
worker processes; one heavy import drags the whole training stack into
`--help` and into a CPU-only CI runner. Import inside the function when you
genuinely need one.
2. **Spec fields hold string identifiers, not callables.** The advantage
computation runs in the Ray Serve `Advantages` process while the policy loss
runs in the Megatron worker, and those two import different module subsets.
Only the algorithm name crosses the process boundary; each side resolves it
against its own table.
3. **Do not hand-edit the `ALGOS` role table.** It is derived from the registry,
so a new algorithm gets the standard RL role set automatically.

## How Much Does Adding One Cost

Honestly: **not "one dict entry".**

| Situation | Files to touch |
|---|---|
| Reuses existing reward normalization / advantage / policy loss, just combined differently | 1 (`spec.py`) |
| Needs new maths (a new advantage formula, say) | 2-3 (`spec.py` plus the implementation module) |
| Also needs new command-line options | 4-6 (the above, plus the option and its validation in `arguments.py`, plus an example and docs) |

What the registry removes is one algorithm name being interpreted in six
scattered if/elif chains — not the cost of adding an algorithm. An algorithm
that needs both new maths and new options lands in the last row.

The `ALGOS` role table is the one part that genuinely costs nothing: it derives
itself from the registry.

## Steps

### 1. Add a spec entry

Edit `ALGORITHM_SPECS` in `relax/algorithms/spec.py`:

```python
"my_algo": AlgorithmSpec(
name="my_algo",
reward_normalizer="group_mean_std", # reuse an existing one, or see step 2
advantage_fn="grpo_broadcast",
policy_loss_fn="ppo_clip",
),
```

If your algorithm is identical to an existing one at some stage, reuse that
identifier. GRPO, GSPO, SAPO and CISPO are equivalent at the advantage layer,
so all four share `"grpo_broadcast"`.

Capability fields:

| Field | Effect |
|-------|--------|
| `kl_level` | `"token"` or `"sequence"` (GSPO constrains the sequence) |
| `needs_full_log_probs` | Whether the loss needs CP-gathered full log probs |
| `advantage_normalization` | What `--normalize-advantages` does: `"whiten"` (masked whitening) or `"token_global"` (REINFORCE++'s global token-level normalization, which also switches on the mask-safe loss reducer) |
| `needs_critic` | Whether a critic service is required; drives `args.use_critic` |
| `requires_normalize_advantages` | Demand `--normalize-advantages` |
| `forbids_normalize_advantages` | Reject `--normalize-advantages` (the estimator keeps the advantage's scale on purpose) |
| `requires_rewards_normalization` | Reject `--disable-rewards-normalization` |
| `min_group_size` | Floor on `--n-samples-per-prompt` |
| `forbids_reward_side_kl` | Demand `--kl-coef 0`; there is nowhere to put a reward-side KL term (`--use-kl-loss` is unaffected) |
| `requires_global_token_loss` | Demand `--calculate-per-token-loss`; the per-sample token-mean reducer would reweight responses by `1 / response_length` |
| `requires_on_policy_updates` | Rejects five knobs at once: `--fully-async` / `--hybrid`, `--max-staleness != 0`, `--num-steps-per-rollout != 1`, `rollout_batch_size * n_samples != global_batch_size`, and `--partial-rollout` / `--use-dynamic-global-batch-size`. For objectives with no importance-ratio correction |

The four `validate_*` functions in `relax/utils/arguments.py` consume every
field in that table except `kl_level`, `needs_full_log_probs` and
`advantage_normalization`, so for the rest, declaring the field is enough — you
do not add an `if` there. (They are four rather than one because argument
validation has a derivation order: `--kl-coef` has to be settled before
validation demands that `--ref-load` exist on disk, and the one-update equality
cannot be checked until `global_batch_size` has taken its final value. Neither
has anything to do with the algorithm being special.) Those three fields are
read in `relax/backends/megatron/loss.py` instead: a genuinely new value needs a
branch there, an existing one does not.

### 2. Write pure functions for genuinely new maths

Only needed when your algorithm differs from every existing one at that stage.

**Reward normalization** (`relax/algorithms/rewards.py`), signature
`fn(args, samples, raw_rewards) -> list[float]`:

```python
def normalize_my_strategy(args, samples, raw_rewards):
positions_by_group = group_positions(samples, args.n_samples_per_prompt)
...
return normalized # one scalar per sample

REWARD_NORMALIZERS["my_strategy"] = normalize_my_strategy
```

The output must be **one scalar per sample**. That constraint is what keeps the
TransferQueue schema fixed — an algorithm reading several reward components collapses them
components to a scalar here.

**Advantage estimator** (`relax/algorithms/advantages.py`), signature
`fn(args, *, rewards, kl, loss_masks, response_lengths, total_lengths, values)`
returning `(advantages, returns)`, both `list[Tensor]`:

```python
def advantage_my_algo(args, *, rewards, kl, **_unused):
...
return advantages, returns

ADVANTAGE_FNS["my_algo"] = advantage_my_algo
```

**Policy loss** (`relax/algorithms/policy.py`), signature
`fn(args, *, log_probs, ppo_kl, advantages) -> (pg_loss, pg_clipfrac)`. The
underlying kernels take different argument lists; the adapter normalizes them.

### 3. Write unit tests

Tests under `tests/algorithms/` need only torch — no megatron, ray or
transfer_queue:

```bash
pytest tests/algorithms/ -v
```

Cover at least:

- Registration and dispatch: the name is in `ALGORITHM_SPECS`, capability fields
match expectations, an unregistered name raises.
- Numerics: hand-compute a small case as the reference. Do not use all-zero or
all-equal rewards — every formula returns 0 on those, so the test proves
nothing.
- Degenerate cases: a group where all rewards are equal, boundary values of
`n_samples_per_prompt`, missing fields, non-numeric input.
- **When changing an existing algorithm**: freeze the old implementation into
the test file as a reference and compare bit-for-bit
(`view(torch.int32).equal`). Do not use `allclose` — its default tolerance is
wide enough to swallow the difference between a biased and an unbiased
standard deviation. `tests/algorithms/test_reward_normalizers.py` is a
worked example.

### 4. Add an example and documentation

- `examples/<algo>/`: a launch script, plus a custom reward function if needed.
- `docs/{zh,en}/examples/algorithms.md`: how it works, the parameter table, a
quick start, and **known deviations** — write down where the implementation
differs from the paper rather than leaving users to discover it.

## Arguments

Algorithm-specific options go in `add_algo_arguments` in
`relax/utils/arguments.py`. The `--advantage-estimator` choices come from
`list_algorithm_names()`, so registering is enough; there is no name list to
maintain.

Put cross-argument validation in `validate_algorithm_args`, and prefer
expressing it through a spec field over comparing algorithm names — the latter
is exactly what this registry exists to remove.

## References

- [Algorithm Reference](../examples/algorithms.md)
2 changes: 1 addition & 1 deletion docs/en/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ bash scripts/training/text/run-qwen3-4B-fp16-8xgpu.sh \

| Parameter | Type | Default | Options | Description |
|-----------|------|---------|---------|-------------|
| `--advantage-estimator` | str | grpo | `grpo`, `gspo`, `reinforce_plus_plus`, `reinforce_plus_plus_baseline`, `ppo`, `sapo`, `cispo` | Advantage estimator. OPD is independent of this choice; enable it with `--use-opd` and its KL/loss coefficient |
| `--advantage-estimator` | str | grpo | generated from `ALGORITHM_SPECS` in `relax/algorithms/spec.py`; currently `grpo`, `gspo`, `sapo`, `cispo`, `rloo`, `ppo`, `reinforce_plus_plus`, `reinforce_plus_plus_baseline` | Advantage estimator. `--help` is authoritative: the choices are read from the registry, so a new algorithm appears there without this table being edited. OPD is independent of this choice; enable it with `--use-opd` and its KL/loss coefficient |
| `--normalize-advantages` | flag | False | - | Whether to normalize advantages |
| `--disable-grpo-std-normalization` | flag | - | - | Disable GRPO standard deviation normalization (from [Dr.GRPO](https://arxiv.org/pdf/2503.20783)) |
| `--disable-rewards-normalization` | flag | - | - | Disable reward normalization |
Expand Down
127 changes: 127 additions & 0 deletions docs/zh/guide/adding-an-algorithm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# 接入一个新算法

Relax 的算法通过 `relax/algorithms/` 下的注册表接入。一个算法名不再散落在各处的 `if/elif` 里——它由一条 `AlgorithmSpec` 描述,各个阶段按需查表。

## 注册表的结构

```
relax/algorithms/
├── spec.py AlgorithmSpec 定义 + ALGORITHM_SPECS 注册表
├── rewards.py reward 归一化策略 + REWARD_NORMALIZERS
├── advantages.py advantage 估计器 + ADVANTAGE_FNS
└── policy.py policy loss 适配器 + POLICY_LOSS_FNS
```

三条硬约束:

1. **`relax/algorithms/` 下禁止顶层 import 重依赖**——不能有 `megatron`、`ray`、`transfer_queue`、`tensordict`、`relax.components`、`relax.backends`。注册表会被参数解析和两个 worker 进程 import;一个重依赖会把整个训练栈拖进 `--help` 和只有 CPU 的 CI。确实需要时在函数内 import。
2. **spec 的字段存字符串标识符,不存函数引用**。advantage 计算跑在 Ray Serve 的 `Advantages` 进程,policy loss 跑在 Megatron worker 进程,两者 import 的模块子集不同。跨进程只传算法名,各进程本地查表。
3. **`ALGOS` 角色表不用手改**。它从注册表自动派生,新算法自动获得标准 RL 角色集合。

## 接入一个新算法要改多少

先说实话:**不是「加一条 dict entry」就完事**。

| 情况 | 要改的文件 |
|---|---|
| 复用现成的 reward 归一化 / advantage / policy loss,只是组合方式不同 | 1 个(`spec.py`) |
| 需要一种新的数学(如新的 advantage 公式) | 2–3 个(`spec.py` + 对应的实现模块) |
| 还需要新的命令行参数 | 4–6 个(上述 + `arguments.py` 的参数声明与校验 + 示例 + 文档) |

注册表消除的是「同一个算法名散落在 6 处 if/elif」,不是「新增算法零成本」。

`ALGOS` 角色表是唯一真正做到零改动的部分——它从注册表自动派生。

## 步骤

### 1. 加一条 spec

编辑 `relax/algorithms/spec.py` 的 `ALGORITHM_SPECS`:

```python
"my_algo": AlgorithmSpec(
name="my_algo",
reward_normalizer="group_mean_std", # 复用现成的,或见第 2 步
advantage_fn="grpo_broadcast",
policy_loss_fn="ppo_clip",
),
```

如果新算法在某个阶段与已有算法完全一致,直接复用那个标识符即可——例如 GRPO / GSPO / SAPO / CISPO 在 advantage 层完全等价,四者共享 `"grpo_broadcast"`。

可用的能力字段:

| 字段 | 作用 |
|------|------|
| `kl_level` | `"token"` 或 `"sequence"`(GSPO 用序列级) |
| `needs_full_log_probs` | loss 是否需要 CP all-gather 后的完整 log probs |
| `advantage_normalization` | `--normalize-advantages` 的归一化方式:`"whiten"`(掩码白化)或 `"token_global"`(REINFORCE++ 的全局 token 级归一化,同时切换掩码安全的 loss reducer) |
| `needs_critic` | 是否需要 critic 服务,驱动 `args.use_critic` |
| `requires_normalize_advantages` | 强制要求 `--normalize-advantages` |
| `forbids_normalize_advantages` | 禁止 `--normalize-advantages`(算法刻意保留了 advantage 的尺度时) |
| `requires_rewards_normalization` | 禁止 `--disable-rewards-normalization` |
| `min_group_size` | `--n-samples-per-prompt` 的下限 |
| `forbids_reward_side_kl` | 要求 `--kl-coef 0`(reward 侧 KL 项无处可放;`--use-kl-loss` 不受影响) |
| `requires_global_token_loss` | 强制要求 `--calculate-per-token-loss`(否则按样本取 token 均值,会按 `1 / response_length` 重新加权) |
| `requires_on_policy_updates` | 一次性拒绝五项:`--fully-async` / `--hybrid`、`--max-staleness != 0`、`--num-steps-per-rollout != 1`、`rollout_batch_size * n_samples != global_batch_size`、`--partial-rollout` / `--use-dynamic-global-batch-size`。适用于没有重要性比值修正的目标函数 |

表里除 `kl_level`、`needs_full_log_probs` 和 `advantage_normalization` 之外的字段,都由 `relax/utils/arguments.py` 的四个 `validate_*` 函数统一消费,**声明即生效**,不需要再去 `arguments.py` 加 `if`。(拆成四个是因为参数校验本身有推导顺序——例如 `--kl-coef` 必须在「检查 `--ref-load` 是否存在」之前判掉,one-update 等式必须在 `global_batch_size` 定稿之后判——与算法特殊性无关。)那三个字段是在 `relax/backends/megatron/loss.py` 里读的:新增一个前所未有的取值需要在那里加分支,复用已有取值则不用。

### 2. 需要新公式时,写纯函数并登记

只有当新算法在某个阶段的数学与现有算法都不同时才需要这一步。

**Reward 归一化**(`relax/algorithms/rewards.py`),签名固定为 `fn(args, samples, raw_rewards) -> list[float]`:

```python
def normalize_my_strategy(args, samples, raw_rewards):
positions_by_group = group_positions(samples, args.n_samples_per_prompt)
...
return normalized # 每个 sample 一个标量

REWARD_NORMALIZERS["my_strategy"] = normalize_my_strategy
```

产出必须是**每个 sample 一个标量**。这条约束让 TransferQueue 的 schema 保持不变——即使算法内部要看多个奖励分量,也要在这一层收敛成一个标量。

**Advantage 估计器**(`relax/algorithms/advantages.py`),签名 `fn(args, *, rewards, kl, loss_masks, response_lengths, total_lengths, values) -> (advantages, returns)`,两者都是 `list[Tensor]`:

```python
def advantage_my_algo(args, *, rewards, kl, **_unused):
...
return advantages, returns

ADVANTAGE_FNS["my_algo"] = advantage_my_algo
```

**Policy loss**(`relax/algorithms/policy.py`),签名 `fn(args, *, log_probs, ppo_kl, advantages) -> (pg_loss, pg_clipfrac)`。底层算子签名不一致,适配器负责统一。

### 3. 写单测

`tests/algorithms/` 下的测试不依赖 megatron / ray / transfer_queue,只要 torch 就能跑:

```bash
pytest tests/algorithms/ -v
```

至少覆盖:

- 注册与分发:算法名在 `ALGORITHM_SPECS` 里;能力字段与预期一致;未注册名报错。
- 数值:手算一个小例子做对照,别用全零或全相同的 reward——那种输入下任何公式都输出 0,测不出东西。
- 退化场景:组内 reward 全相同、`n_samples_per_prompt` 取边界值、缺字段、非数值输入。
- **改动已有算法时**:把旧实现冻结进测试文件当参照,逐位对拍(`view(torch.int32).equal`),不要用 `allclose`——它的默认容差足以吞掉无偏/有偏标准差的差异。`tests/algorithms/test_reward_normalizers.py` 是现成范例。

### 4. 加示例与文档

- `examples/<algo>/`:启动脚本,必要时附自定义 reward 函数。
- `docs/{zh,en}/examples/algorithms.md`:算法原理、关键参数表、快速开始,以及**已知偏差**——实现与论文不一致的地方要写出来,不要留给使用者去发现。

## 参数

新增算法专用参数时改 `relax/utils/arguments.py` 的 `add_algo_arguments`。`--advantage-estimator` 的 `choices` 由 `list_algorithm_names()` 生成,注册即可用,不需要手动维护名单。

跨参数的校验写进 `validate_algorithm_args`,并优先用 spec 字段表达而不是比较算法名——后者正是这套注册表要消除的东西。

## 参考

- [算法参考](../examples/algorithms.md)
2 changes: 1 addition & 1 deletion docs/zh/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ bash scripts/training/text/run-qwen3-4B-fp16-8xgpu.sh \

| 参数 | 类型 | 默认值 | 可选值 | 说明 |
|------|------|--------|--------|------|
| `--advantage-estimator` | str | grpo | `grpo`, `gspo`, `reinforce_plus_plus`, `reinforce_plus_plus_baseline`, `ppo`, `sapo`, `cispo` | 优势估计器。OPD 独立于该选项,使用 `--use-opd` 及对应 KL/loss 系数启用 |
| `--advantage-estimator` | str | grpo | 由 `relax/algorithms/spec.py` 的 `ALGORITHM_SPECS` 生成,当前为 `grpo`、`gspo`、`sapo``cispo`、`rloo`、`ppo`、`reinforce_plus_plus`、`reinforce_plus_plus_baseline` | 优势估计器。以 `--help` 为准:取值直接读注册表,新增算法无需改这张表即可出现。OPD 独立于该选项,使用 `--use-opd` 及对应 KL/loss 系数启用 |
| `--normalize-advantages` | flag | False | - | 是否归一化优势 |
| `--disable-grpo-std-normalization` | flag | - | - | 禁用 GRPO 标准差归一化(来自 [Dr.GRPO](https://arxiv.org/pdf/2503.20783)) |
| `--disable-rewards-normalization` | flag | - | - | 禁用 reward 归一化 |
Expand Down
Loading
Loading