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:
- Collect or prepare all trajectory chunks belonging to one optimizer step before issuing the first backward call.
- Apply filtering/transformation first, so counts reflect the datums and masks that will actually train.
- 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
- Give every pass the same optimizer-batch denominator.
- 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:
- Construct one fixed synthetic optimizer batch with unequal pass token counts.
- Run it as one forward/backward call.
- Run the same batch split across
K > 1 accumulated calls.
- Compare gradients within numerical tolerance for
token-mean and both sequence-mean modes.
- Confirm
token-sum remains an intentional raw sum.
- 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
Summary
PR #781 has the right ownership goal: rLLM should define loss normalization explicitly, and Fireworks should receive
GradAccNormalization.NONErather 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:
Multiple forward/backward calls per optimizer step
The fully async trainer supports:
It schedules
Kforward/backward passes and performs one optimizer step after all nonempty passes:unified_trainer.pyaccumulation loopAsyncTrainingConfigfield definitionsFor example:
normally means eight training-client calls, one per nonempty pass, followed by one optimizer step. Builtin GRPO uses
forward_backward; custom losses such asdppo_tvuseforward_backward_custom.Correct client-owned global token mean
For pass
j, let:S_jbe its summed token-gradient vector;N_jbe its active-token count;N_global = sum_j N_jover every accepted datum contributing to this optimizer step.The desired optimizer-batch token mean is:
If normalization is fully owned by rLLM, it must know
N_globalbefore the first backward call and scale every pass using that same denominator:Accumulation then gives:
The server can use:
because every contribution is already globally scaled according to an rLLM-computed denominator.
Custom-loss behavior after #781
The custom path now calls:
The closure only sees the current pass, so it divides by local
N_j:build_custom_losslocal divisorGradAccNormalization.NONEThe accumulated custom-loss gradient is therefore:
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 byN_global / N_j, generally changing the scale and potentially changing the direction. Dividing the final result byKwould still be an unweighted mean of pass means, not a token mean, unless everyN_jwere 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-sumplusGradAccNormalization.NONEis valid and additive across passes. The issue is that #781 uses raw token-sum even whenloss_agg_mode=token-meanor a sequence-mean mode was requested:For requested token mean:
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=Truemeant 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:
This builtin observation independently confirms that the optimizer applied one global
NUM_LOSS_TOKENSdivisor. It is not an end-to-end custom-loss observation; the custom path's raw-sum behavior follows from the linked code.grad_norm_rmsis per-parameter-element RMS telemetry:It does not rescale the optimizer gradient.
Required implementation direction
rLLM should own both the denominator and its accumulation scope:
GradAccNormalization.NONE; Fireworks must not infer token or sequence counts.For custom losses, extend
build_custom_lossto 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:
K > 1accumulated calls.token-meanand both sequence-mean modes.token-sumremains an intentional raw sum.The result of one optimizer step should be invariant to
fwd_bwd_group_size.Related: #781