Skip to content

Fireworks loss normalization depends on fwd/bwd chunking after #781 #782

Description

@signalrush

Summary

PR #781 has the right ownership goal: rLLM should define loss normalization explicitly, and Fireworks should receive GradAccNormalization.NONE rather than infer a denominator.

The problem is the scope of the client-side denominator. The current custom-loss implementation normalizes each forward/backward chunk independently. When one optimizer batch is split across multiple accumulated forward/backward calls, that produces a sum of chunk means rather than a mean over the complete optimizer batch.

The required invariant is:

Changing fwd_bwd_group_size must not change the loss represented by one optimizer step.

Multiple forward/backward calls per optimizer step

The fully async trainer supports:

K = mini_batch_size // fwd_bwd_group_size

It schedules K forward/backward passes and performs one optimizer step after all nonempty passes:

For example:

rllm.async_training.mini_batch_size: 8
rllm.async_training.fwd_bwd_group_size: 1

normally means eight training-client calls, one per nonempty pass, followed by one optimizer step. Builtin GRPO uses forward_backward; custom losses such as dppo_tv use forward_backward_custom.

Correct client-owned global token mean

For pass j, let:

  • S_j be its summed token-gradient vector;
  • N_j be its active-token count;
  • N_global = sum_j N_j over every accepted datum contributing to this optimizer step.

The desired optimizer-batch token mean is:

G_expected = (sum_j S_j) / N_global

If normalization is fully owned by rLLM, it must know N_global before the first backward call and scale every pass using that same denominator:

pass_j contribution = S_j / N_global

Accumulation then gives:

sum_j (S_j / N_global) = (sum_j S_j) / N_global

The server can use:

GradAccNormalization.NONE

because every contribution is already globally scaled according to an rLLM-computed denominator.

Custom-loss behavior after #781

The custom path now calls:

build_custom_loss(..., server_normalized=False)

The closure only sees the current pass, so it divides by local N_j:

The accumulated custom-loss gradient is therefore:

G_actual = sum_j (S_j / N_j)

If all pass sizes are equal, G_actual = K * G_expected; with eight passes it is exactly 8x. With unequal token counts, shorter passes are overweighted by N_global / N_j, generally changing the scale and potentially changing the direction. Dividing the final result by K would still be an unweighted mean of pass means, not a token mean, unless every N_j were identical.

Sequence-mean modes have the same issue if the global sequence denominator is replaced by one denominator per pass.

Builtin-loss behavior after #781

Builtin raw token-sum plus GradAccNormalization.NONE is valid and additive across passes. The issue is that #781 uses raw token-sum even when loss_agg_mode=token-mean or a sequence-mean mode was requested:

For requested token mean:

G_actual_builtin = sum_j S_j
G_expected        = (sum_j S_j) / N_global

This differs by the optimizer batch's token count. Adam can be approximately invariant to a stable uniform rescaling when epsilon and clipping are inactive, but variable token counts, gradient clipping, epsilon, weight decay, and existing optimizer moments can make the training behavior materially different.

Why this is not evidence of prior double normalization

Before #781, server_normalized=True meant that the custom closure returned a raw sum; it did not divide client-side:

That older implementation delegated one global division to Fireworks. We should replace that delegation with an rLLM-computed global denominator, but it was not a client division followed by the same server division.

Separately, one builtin-GRPO optimizer step reported:

grad_norm_pre_norm = 3316.261962890625
global-normalized grad_norm = 0.00046834436943754554
ratio = approximately 7,080,820

This builtin observation independently confirms that the optimizer applied one global NUM_LOSS_TOKENS divisor. It is not an end-to-end custom-loss observation; the custom path's raw-sum behavior follows from the linked code.

grad_norm_rms is per-parameter-element RMS telemetry:

||g||_2 / sqrt(number of gradient elements)

It does not rescale the optimizer gradient.

Required implementation direction

rLLM should own both the denominator and its accumulation scope:

  1. Collect or prepare all trajectory chunks belonging to one optimizer step before issuing the first backward call.
  2. Apply filtering/transformation first, so counts reflect the datums and masks that will actually train.
  3. Compute the optimizer-batch normalization metadata in rLLM:
token-sum              -> denominator 1
token-mean             -> total active tokens across all passes
seq-mean-token-sum     -> total trainable sequences across all passes
seq-mean-token-mean    -> per-sequence token mean, then total trainable sequences
  1. Give every pass the same optimizer-batch denominator.
  2. Keep GradAccNormalization.NONE; Fireworks must not infer token or sequence counts.

For custom losses, extend build_custom_loss to accept an explicit global normalization value rather than deriving it from the current pass.

For builtin losses, rLLM must apply the client-computed global scale to each pass's loss contribution before sending it. If a builtin kernel exposes only advantages/weights, scaling those is exact only for loss terms linear in that quantity. Kernels containing non-advantage terms need an explicit client-provided loss scale or must use the custom-loss path; silently leaving the requested aggregation mode unapplied is not sufficient.

Dropped/empty passes contribute neither numerator nor denominator. The count must come from the final action/loss mask, not from a server-defined notion such as nonzero-gradient tokens.

Regression tests

For both custom and representable builtin paths:

  1. Construct one fixed synthetic optimizer batch with unequal pass token counts.
  2. Run it as one forward/backward call.
  3. Run the same batch split across K > 1 accumulated calls.
  4. Compare gradients within numerical tolerance for token-mean and both sequence-mean modes.
  5. Confirm token-sum remains an intentional raw sum.
  6. Include an empty/dropped pass and verify it changes neither numerator nor denominator.

The result of one optimizer step should be invariant to fwd_bwd_group_size.

Related: #781

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