Algorithm Recipes
A recipe is a frozen TrainingConfig in rl_engine/trainer/recipes.py — roles, syncs, and specializations resolved into workers at launch. Includes experimental diffusion recipes (RWFM, NFT, DPO, MMDiT).
1 recipes.py — TrainingConfig
All algorithms live in TRAINING_CONFIGS (rl_engine/trainer/recipes.py) as frozen TrainingConfig dataclasses (rl_engine/trainer/config.py). Three dataclass types cover the entire authoring surface:
Named model roles
Each ModelRole pairs a name with a specialization class that owns compute_loss().
Declared data syncs
Cross-role dependencies are DataSync objects — inject (Ray object store) or exchange (bidirectional NCCL).
Zero backend changes
Adding an algorithm means one @recipe function. Megatron/FSDP backends and the trainer are untouched.
| Dataclass | Field | Default | Meaning |
|---|---|---|---|
ModelRole | name | — | Role key, e.g. "policy", "value", "reference". |
specialization_cls | — | Class defining compute_loss() / prepare_batch() / compute_advantages(). | |
backend | "megatron" | Training backend: "megatron" or "fsdp". | |
trainable | True | False for frozen reference models (no optimizer step). | |
num_nodes | None | Node count; None resolves from args (actor_num_nodes). | |
num_gpus_per_node | None | GPUs/node; None resolves from args. | |
init_kwargs | {} | Extra kwargs to async_init, e.g. with_ref=True. | |
shares_pool_with | None | Name of another role to share a WorkerPool (same GPUs/model). | |
sync_to_rollout | False | Push trained weights to the rollout inference engine (SGLang) after each step. | |
DataSync | source / target | — | Producing / consuming role names. |
keys | — | Source output keys to transfer, e.g. ("values",). | |
stage | "inject" | "inject" (Ray object store) or "exchange" (NCCL). Validated in __post_init__. | |
target_keys | None | Optional rename on the target side; must match keys length. | |
TrainingConfig | name | — | Algorithm name (matches the registry key). |
roles | — | Ordered tuple of ModelRole. | |
syncs | () | Tuple of DataSync dependencies. | |
description | "" | Human-readable one-liner (shown in --help / logs). |
TrainingConfig.__post_init__ validates at import time: unique role names, sync endpoints resolve, non-empty keys, pool-sharing acyclic — a malformed recipe fails at import, not mid-run.
The @recipe decorator
# rl_engine/trainer/recipes.py TRAINING_CONFIGS: dict[str, TrainingConfig] = {} def recipe(name: str): """Decorator to register a training config.""" def decorator(fn): TRAINING_CONFIGS[name] = fn() # build + register at import return fn return decorator
factory.create_trainer() (rl_engine/trainer/factory.py) builds a RoleWorkers per role, connects exchange syncs over NCCL, and hands the graph to StacxTrainer.
2 Recipe catalog
22 recipes ship in recipes.py — 16 LLM plus 6 experimental diffusion.
LLM recipes
| Recipe | Roles | Syncs | Specialization | Typical launcher |
|---|---|---|---|---|
grpo | policy (with_ref) | none | ActorSpecialization | grpo/grpo_4b.sh |
gspo | policy | none | ActorSpecialization | --advantage-estimator gspo |
reinforce_pp | policy | none | ActorSpecialization | --advantage-estimator reinforce_plus_plus |
reinforce_pp_baseline | policy | none | ActorSpecialization | reinforce_plus_plus_baseline |
ppo | policy, value | 2 exchange | Actor + Critic | --algorithm ppo |
ppo_colocated | policy | none | DualRoleSpecialization | --algorithm ppo_colocated |
ppo_colocated_async | policy, value | 2 exchange | Actor + Critic | --algorithm ppo_colocated_async |
ppo_colocated_async_shared | policy, value shared pool | none | Actor + Critic | ppo_colocated_async_shared |
dpo | policy (with_ref) | none | DPOSpecialization | --algorithm dpo |
dpo_distributed | policy, reference | 1 inject | DPOSpecialization | --algorithm dpo_distributed |
online_reward | policy, reward | 1 inject | Actor + Reward | --algorithm online_reward |
sft | policy | none | CausalDecoderSpecialization | --algorithm sft |
online_dagger | policy | none | CausalDecoderSpecialization | sft/sft_4b.sh (iter-SFT) |
on_policy_distillation | policy | none | DistillSpecialization | opd/opd_4b.sh |
mixture_dagger_opd | policy | none | MixedLossDaggerOPD | online_dagger/dagger_4b.sh |
mixture_aggrevate_opd | policy | none | MixedLossDaggerOPD | online_dagger/aggrevate_4b.sh |
Diffusion recipes experimental
Flow-matching kernels for DiT/MMDiT models; loss math in rl_engine/trainer/objectives/diffusion_loss.py.
| Recipe | Specialization | Objective |
|---|---|---|
diffusion_rwfm | DiffusionSpecialization | Reward-weighted flow matching — advantage-weighted denoising MSE on z_0. |
diffusion_sft | DiffusionSpecialization | Supervised flow matching (no advantages). |
diffusion_dpo | DiffusionDPOSpecialization | Preference-weighted denoising; needs precomputed ref_loss injected. |
diffusion_nft | DiffusionNFTSpecialization | Noise-Free Reward Tuning; with_ref=True + frozen old_actor snapshot. |
mmdit_rwfm | MMDiTSpecialization | RWFM for dual-stream Flux / SD3 / Lumina models. |
mmdit_nft | MMDiT+NFT (mixed MRO) | NFT loss with MMDiT dual-stream input packing. |
Recipe selection — infer_algorithm()
The runtime key args.algorithm (rl_engine/trainer/factory.py) resolves as:
--algorithmset explicitly → wins verbatim (DAgger / distillation / diffusion always use this).use_criticset →ppo.- Otherwise
--advantage-estimatormaps to the matching recipe (e.g.reinforce_plus_plus → reinforce_pp).
3 Algorithm objectives
Stateless loss functions in rl_engine/trainer/objectives/; specializations in rl_engine/trainer/specialization/ compose them inside compute_loss().
GRPO — PPO-clipped PG on group-normalized rewards
ActorSpecialization.compute_loss (specialization/agent.py). compute_grpo_advantages() (objectives/advantage.py) broadcasts the scalar reward to every response token and whitens across the group. Optional KL anchor: --use-kl-loss --kl-loss-type low_var_kl --kl-loss-coef 0.001 + --ref-load (in-Megatron reference, no separate worker). gspo and reinforce_pp[_baseline] reuse this specialization with a different estimator.
SFT & iter-SFT — supervised cross-entropy
CausalDecoderSpecialization.compute_loss (specialization/causal_decoder.py): per-token CE on the response, masked to loss_masks. The iter-SFT baseline uses --algorithm online_dagger with STACX_DAGGER_SFT_MODE=trajectory to replay a pre-saved teacher corpus instead of live rollout.
On-policy distillation (OPD) — K3 reverse-KL vs. a frozen teacher
DistillSpecialization (specialization/distill.py): reverse KL against a frozen teacher SGLang server, whose per-token logprobs arrive as rollout fields. Mode chosen by STACX_DISTILL_KL_MODE:
k3_sample(default) — single-sample MC estimate on the chosen token; TP-aware, higher variance.topk(TP=1 only) — RKL over the union of student and teacher top-K supports; lower variance.
Mixed-loss DAgger-OPD — per-token CE / K3 dispatch
MixedLossDaggerOPDSpecialization (specialization/mixed_loss_dagger.py) routes each action token by executor — teacher-executed → CE, student-executed → K3 reverse-KL — via a sign sentinel packed into teacher_log_probs (SGLang logprobs are ≤ 0; rollout writes +1.0 at teacher positions). WandB tracks ce_mean, k3_mean, teacher_action_frac.
Both endpoints asserted in tests/trainer/test_mixed_loss_dagger.py.
Rollin protocols — β vs. κ
Both recipes share the loss kernel but differ in how the teacher/student split is produced (STACX_DAGGER_SAMPLING_MODE):
mixture_dagger_opd— per-turn β-mixture: each turn flipsBernoulli(β), β decaying over training (linear/cosine/exponential/step; launcher default linear 1.0→0.0 over 5 iters).mixture_aggrevate_opd— trajectory-level AggreVaTe: κ ~ U{0, T_max(i)} per task; student leads turns 0..κ−1, teacher force-decodes the tail. T_max(i) = min(start + step·i, cap), launcher default 0/20/100.
DPO — contrastive preference loss
dpo / dpo_distributed
compute_dpo_loss (objectives/dpo.py): L = −log σ(β·(Δ_w − Δ_l)) with log-ratios vs. a reference (IPO / cDPO variants supported). Colocated switches ref weights in place; distributed runs a frozen reference role with a log_probs → ref_log_probs inject sync.
4 Data syncs: inject vs. exchange
inject
One-way via the Ray object store: the trainer runs forward_only() on the source, pulls keys (optionally renamed to target_keys), and merges them into target rollout data. No NCCL group.
Used by: dpo_distributed (log_probs→ref_log_probs), online_reward (reward_scores).
exchange
Bidirectional concurrent NCCL: both roles call train() simultaneously and swap tensors mid-step.
Used by: ppo / ppo_colocated_async — critic → values; actor → log_probs, ref_log_probs.
Single-role recipes declare syncs=(); the external SGLang teacher server needs no sync entry.
5 Adding an algorithm
A new algorithm is a specialization (only if no existing loss fits) plus one @recipe. No backend, Ray, or orchestrator changes needed.
Step 1 — Subclass a specialization
# rl_engine/trainer/specialization/my_algo.py from collections.abc import Callable from typing import Any import torch from rl_engine.trainer.specialization.causal_decoder import CausalDecoderSpecialization from rl_engine.trainer.objectives.policy_loss import sum_of_sample_mean class MyAlgoSpecialization(CausalDecoderSpecialization): def compute_loss( self, model_output: torch.Tensor, # [seq_len, vocab] (TP-local shard if TP>1) batch: dict[str, Any], reducer: Callable[[torch.Tensor], torch.Tensor], ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: response_lengths = batch["response_lengths"] loss_masks = batch["loss_masks"] # ... derive per-token student logprobs from model_output, then: per_token = my_kernel(...) # shape [N_response] loss = sum_of_sample_mean(per_token, response_lengths, loss_masks) loss = reducer(loss) # slime CP-aware reduction return loss, {"my_loss": loss.detach()}
Step 2 — Register the recipe
# rl_engine/trainer/recipes.py @recipe("my_algo") def my_algo_config(): from rl_engine.trainer.specialization.my_algo import MyAlgoSpecialization return TrainingConfig( name="my_algo", roles=( ModelRole( name="policy", specialization_cls=MyAlgoSpecialization, sync_to_rollout=True, # push weights to SGLang each step ), ), syncs=(), # single role → no cross-model sync description="My custom reverse-KL algorithm", )
Step 3 — Launch it
python -m rl_engine.train --algorithm my_algo \ --hf-checkpoint /root/models/<student> \ --ref-load /root/models/<student>_torch_dist \ ... # the SWE launchers under scripts/train/swe/ wrap this
Scope of a recipe change
Adding a second model is one more ModelRole + DataSync — the trainer wires the inject / exchange transport automatically; no orchestrator code.
6 Objectives reference
| Module | Function | Role |
|---|---|---|
policy_loss.py | compute_ppo_loss() | PPO clipped PG loss (+ optional dual-clip). |
policy_loss.py | sum_of_sample_mean() | Sum-of-per-sample-means reduction (CP-aware denominator). |
value_loss.py | compute_clipped_value_loss() | PPO-style clipped value loss. |
logprob.py | compute_log_probs_and_entropy() | Logprob + entropy extraction from logits. |
kl.py | compute_kl_divergence() | K1 / K2 / K3 / low_var_kl estimators. |
advantage.py | compute_grpo_advantages() / compute_grpo_returns() | GRPO reward broadcast + whitening. |
advantage.py | compute_chunked_gae() | Parallel-prefix GAE for PPO. |
advantage.py | compute_reinforce_pp_returns() / …_baseline_advantages() | REINFORCE++ returns / baseline advantages. |
advantage.py | apply_opd_kl_penalty() | Advantage-side OPD reverse-KL penalty. |
dpo.py | compute_dpo_loss() | DPO / IPO / cDPO contrastive loss. |
distillation_loss.py | compute_rkl_k3_sample() / compute_rkl_topk() | On-policy distillation reverse-KL (chosen-token / union-top-K). |
divergences.py | compute_k3_kl() / compute_jsd_contribution() | Eval-time one-sample KL / JSD estimators (not training losses). |
diffusion_loss.py | compute_flow_matching_loss() / compute_diffusion_rl_loss() | Flow-matching + reward-weighted diffusion objectives. |
7 Algorithm knobs
Algorithm-level flags and env vars. Full launcher catalog: scripts/train/swe/ENV_REFERENCE.md.
| Flag / env | Applies to | Default | Effect |
|---|---|---|---|
--algorithm | all | inferred | Recipe key into TRAINING_CONFIGS; overrides --advantage-estimator inference. |
--advantage-estimator | grpo family | grpo | Maps to grpo / gspo / ppo / reinforce_pp[_baseline]. |
--use-kl-loss + --kl-loss-type + --kl-loss-coef | GRPO | off / k1 / 0.1 | KL anchor vs. --ref-load. GRPO launcher sets low_var_kl, 0.001. |
--dagger-schedule | dagger | linear | β decay: linear / cosine / exponential / step. |
--dagger-beta-start / --dagger-beta-end | dagger | 1.0 / 0.0 | β at iter 0 / after decay (1.0 = teacher, 0.0 = student). |
--dagger-decay-iters / --dagger-warmup-iters | dagger | 10 / 0 | Iters over which β decays / held at start first. |
--dagger-tmax-start / -step / -cap | aggrevate | 0/0/0 (launcher 0/20/100) | κ-schedule T_max(i)=min(start+step·i, cap); per-task κ~U{0,T_max}. |
STACX_DAGGER_SAMPLING_MODE | dagger | mixture | mixture (per-turn β) or aggrevate_pure (prefix/tail). |
STACX_MIXED_LOSS_DAGGER | dagger | 0 | 1 enables the per-token CE/K3 sentinel dispatch (auto-set for aggrevate). |
STACX_DISTILL_KL_MODE | OPD | k3_sample | k3_sample (TP-any) or topk (TP=1 only). |
STACX_DISTILL_TOPK | OPD (topk) | 20 | Union-top-K size; must match rollout capture K. |