Fix the batch-invariant RMSNorm forward and its input gradient - #6859
Open
huthvincent wants to merge 2 commits into
Open
Fix the batch-invariant RMSNorm forward and its input gradient#6859huthvincent wants to merge 2 commits into
huthvincent wants to merge 2 commits into
Conversation
Contributor
Author
|
@NVIDIA/mcore-oncall — this has been Ready since yesterday and It is two commits in one file ( |
This was referenced Aug 26, 2026
huthvincent
force-pushed
the
fix/B001-batch-invariant-rmsnorm-zero-centered-gamma
branch
from
August 27, 2026 14:45
459ebed to
8851a78
Compare
`BatchInvariantRMSNormFn.forward` computes `weight_eff = weight + 1.0 if zero_centered_gamma else weight` and then never reads it: it builds `w_fp32` from the raw `weight` two lines later and normalizes with that. So `zero_centered_gamma=True` produced output identical to `False`, while `backward` did apply the offset — the forward and the backward disagreed. `--apply-layernorm-1p` reaches this through `TENorm`, which constructs TE's `RMSNorm` with `zero_centered_gamma=config.layernorm_zero_centered_gamma`, and the batch-invariant patch of `RMSNorm.forward` passes it straight through. The offset is applied after the fp32 cast, matching the fused reference in this same file (`w_fp32 = ln_weight.float()` then `if zero_centered_gamma: w_fp32 = w_fp32 + 1.0`), rather than in the weight's dtype. Checked on 8xL4 / torch 2.12: before, `forward(True)` and `forward(False)` are bitwise identical in bf16 and fp32; after, `forward(True)` matches `(x * rsqrt(mean(x^2) + eps)) * (weight + 1)` exactly and the `False` path is bitwise unchanged. Signed-off-by: Rui Zhu <rui.zhu.rz399@yale.edu>
For `y_i = x_i * r * w_i` with `r = (mean_j(x_j^2) + eps)^-1/2` over the last
dimension of size D, `dr/dx_k = -r^3 x_k / D`, so
dL/dx_k = g_k w_k r - (r^3 x_k / D) * sum_i g_i x_i w_i
The weight appears once in each term: explicitly in the first, and inside
`s = sum_i g_i x_i w_i` in the second. `BatchInvariantRMSNormFn.backward`
multiplied the second term by `w_fp32` again, computing
`- w_k r^3 x_k s / D`. The weight gradient on the line above is unaffected.
Because the extra factor is elementwise `w_k`, the error is exactly zero when
every weight is 1 and grows as the weights move away from 1 — so it is invisible
to any check written against unit weights, and it does not depend on
`zero_centered_gamma`.
Checked on L4 / torch 2.12 with a central finite difference of this function's
own forward, `zero_centered_gamma=False`:
weight=randn before: max|analytic - numeric| = 0.9878 after: 2.60e-05
weight=ones before: max|analytic - numeric| = 2.12e-05 after: 2.12e-05
The unit-weight row is the control: it is unchanged, which isolates the
discrepancy to the duplicated elementwise factor.
Signed-off-by: Rui Zhu <rui.zhu.rz399@yale.edu>
huthvincent
force-pushed
the
fix/B001-batch-invariant-rmsnorm-zero-centered-gamma
branch
from
August 31, 2026 00:46
8851a78 to
57dadea
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Fixes two defects in
BatchInvariantRMSNormFn, one commit each. Both date fromBatch Invariance (#2308)and neither has been touched since, so this is not a regression report.1. The forward drops
zero_centered_gamma. It computesweight_eff = weight + 1.0 if zero_centered_gamma else weightand then never reads it — two lines later it buildsw_fp32from the rawweight. Sozero_centered_gamma=Trueproduced output bitwise identical toFalse, whilebackwarddid honour the offset. The sibling fused path in the same file does it correctly (w_fp32 = ln_weight.float()thenif zero_centered_gamma: w_fp32 = w_fp32 + 1.0), and the offset is applied after the fp32 cast here to match it.2. The input gradient multiplies the variance term by the weight twice. For
y_i = x_i r w_iwithr = (mean_j(x_j^2) + eps)^{-1/2}over the last axis of sizeD,dr/dx_k = -r^3 x_k / D, soThe weight appears once per term: explicitly in the first, and inside
s = sum_i g_i x_i w_iin the second.backwardcomputed- w_k r^3 x_k s / D. The weight gradientg_wis correct and unchanged.Because the extra factor is elementwise
w_k, the error is identically zero while every weight is exactly 1 — i.e. at initialisation and never again after the first optimizer step. That is also whytest_te_rmsnorm_paritypasses: it does comparegrad_diff = (x.grad - x_clone.grad).abs().max(), but it never perturbslayer_bik.weightfrom itsones_init, so the one test that could catch this is blind at exactly the weight values it uses.Issue tracking
For PRs from open-source community contributors:
Linked issue: none — 3 changed lines in one file. Happy to open one if you would prefer to track it.
Heads-up on a likely conflict: #4740 rewrites
g_winside this exactbackwardand carries thedxline through as an unmodified context line, so it will conflict with commit 2. #6634 also touches this file but not this function. Say the word and I will rebase behind either.Contribution process
Pre-checks
test_te_rmsnorm_parity, which needs a non-unitweightto be sensitive to commit 2 at all. Happy to add that if you want it in this PR rather than a follow-up.black==26.3.0 --skip-magic-trailing-comma --skip-string-normalization,isort==5.13.2andruff checkall clean on the changed file.How to reproduce, and how we checked it
Where this is live, stated first because it is the main limitation.
batch_invariant_backenddefaults to"te_native", and that path calls_te_patch_for_batch_invariant(skip_gemm=True, skip_rmsnorm=True), which returns atif skip_rmsnorm: returnbeforerms_cls.forward = _te_rmsnorm_forward_patched. So under the default backendBatchInvariantRMSNormFnis never installed and neither defect can fire. They are live under"deepgemm"or"triton", both validated first-class values, withset_batch_invariant_modedefaulting tobackend or "triton";enable_batch_invariant_modeitself defaulted to"deepgemm"until #6521 merged on 2026-08-13. Once the class is patched,_te_rmsnorm_forward_patchedcarries nois_batch_invariant_mode_enabled()guard, so every RMSNorm backward in the step goes through it.Checked on 8xL4 / torch 2.12.0+cu130, plus float64 on CPU. Three independent harnesses were used; none differentiates through
mean_dim, because it is a Triton kernel and autograd is not reliable through it — building a reference that way produced a misleading number once and it was discarded.Commit 1, forward:
Commit 2, input gradient — analytic backward against a finite difference of this function's own forward,
zero_centered_gamma=False:The two fp64 columns are two separately written harnesses. The
weight=onescontrol is what makes this a discriminator rather than a measurement: the extra factor is elementwisew, so the error must vanish at unit weight — and it does, unchanged, while the non-unit case drops to machine epsilon.weight=zerosgives exactly0.0. The forward agrees with an independent reference to0.0, which confines the second defect to the backward.What we did not do. We did not run a training job end to end, and we did not benchmark anything — no performance claim is made anywhere in this PR. transformer_engine is not installed on the machine we tested on, so
BatchInvariantRMSNormFnwas exercised directly rather than through a patchedte.pytorch.RMSNorm.The strongest objection to this change
The default backend does not install this function, so neither defect can fire in a default run — the audience is whoever explicitly selects
deepgemmortriton, plus anyone who adopted batch-invariance before #6521 changed the default. And the second defect is identically zero at initialisation, which is why the existing parity tests and the GRPO train/generation parity runs all pass. A reviewer could reasonably conclude the blast radius is small. Our answer is only that the audience for batch-invariance is people who need bitwise reproducibility, who are the least likely to notice a systematically wrong gradient and the most likely to be misled by it — but that is a judgement about priority, not about correctness, and it is yours to make.