Module:
rllm.trainer.algorithms.loss — exposed at top level as rllm.register_loss and rllm.LossContext.- How the loss hook works (one function, all three backends)
- Built-in losses and how to select one
- The
LossContextcontract and its reducers - Loss-aggregation modes (
loss_agg_mode) - Writing and registering a custom loss (worked example: IcePop)
Core concept
A policy loss is a single function that returns the complete scalar objective and does its own masking and aggregation — the same convention as verl’sPOLICY_LOSS_REGISTRY. You select it by name; there is no list of losses and no separate auxiliary-loss framework (an additive term is composed inside a loss body).
The same function runs on every backend. It reads a normalized LossContext and calls the reducer the backend injects, so a loss body never needs to know whether it is running in verl’s in-process kernel or a Tinker/Fireworks forward_backward_custom pass.
(scalar_loss, metrics) where metrics is a dict[str, float] logged under actor/… (verl) or train/… (managed backends).
Built-in losses
Ported from verl 0.8 and verified against its kernels. Select any of these withalgorithm.loss_fn — no plugin needed.
Common hyperparameters (
eps_clip, eps_clip_high, kl_beta) come from the top-level algorithm config; anything else goes under algorithm.loss_params and lands in ctx.params.
The LossContext contract
Every loss receives a LossContext with these fields (all tensors share a shape — (batch, response_len) on verl, (num_tokens,) per datum on the managed path):
logp_rollout is the true sampling distribution and equals logp_old under bypass_mode; they differ only when a proximal old-policy was recomputed (non-bypass). Reach for it when a loss must reference the rollout distribution independently of the ratio denominator — e.g. train/inference-mismatch corrections like TIS or IcePop.Only
logp_curr carries gradient; everything else is a detached constant. That is what lets the managed backends reproduce dL/dθ from your loss with a forward_backward_custom surrogate.ctx.aggregate(per_token, mask, mode=None)— collapses a per-token tensor to the scalar objective, realizing the configuredloss_agg_modewith global normalization spanning the whole optimizer step. Passmode=to force a specific reduction regardless of config (GSPO does this).ctx.seq_reduce(values, mask, reduction)— reduces per sequence and broadcasts back to per-token (a row on verl’s(B, T); the whole datum on the managed path). This is what enables sequence-level losses like GSPO.
ctx.aggregate.
Loss-aggregation modes
loss_agg_mode is a single, backend-agnostic knob using verl’s names. The loss body just calls ctx.aggregate; each backend realizes the mode with global normalization over the entire optimizer step (all micro-batches, grad-accumulation passes, and DP ranks).
A loss whose math requires a specific reduction pins it at registration and ignores the config:
How each backend runs it
loss_fn resolution is native-first: if the backend has a fused kernel for that name, rLLM uses it; only losses without a native kernel take the custom path.
Writing a custom loss
A loss is any function(ctx) -> (scalar, metrics). The example below is IcePop (arXiv:2510.18855) — it zeros a token’s gradient when its train/inference ratio exp(logp_curr − logp_rollout) leaves a band [alpha, beta] (masking out-of-range tokens rather than clipping them). rLLM already ships this as the built-in icepop (see the table above); it’s reproduced here under a different name as a template you can adapt.
logp_curris the only differentiable term. Anything used as a weight (the ratio, a mask) should be.detach()ed so gradient flows only through the scorelogp_curr.- Use
logp_rolloutfor the true sampling distribution and guard onNonerather than falling back tologp_old(which is the proximal, not the inference log-prob). It’s always populated on the managed backends. - Never reduce by hand. Return
ctx.aggregate(per_token, mask)so your loss inherits the configured aggregation and correct global normalization.
Selecting and registering it
Pointloss_fn at the registered name and list the defining module in loss_plugins so rLLM imports it at startup (which runs the @register_loss decorator on every worker):
rllm.losses entry point in your package, or simply import it before constructing the trainer — any of these makes the name resolvable.
Notes
bypass_mode(async). With one optimizer step per batch, a recomputed proximal equals the current policy, sologp_olddefaults to the rollout log-probs (bypass_mode=Trueon Fireworks) — making the ratio a real trust region against the behavior policy. Setfalseonly for multi-update-per-batch PPO. This is training infrastructure, not part of a loss body.- Sequence-level losses need
ctx.seq_reduce; seegspofor the pattern (length-normalized sequence ratio, then applied per token via the stop-gradient identity). - Tinker normalization is exact only when
fwd_bwd_group_size == mini_batch_size; under grad accumulation the divisor is per-pass. Fireworks and verl normalize globally and are accumulation-invariant.

