Skip to content

Commit a0b7ce1

Browse files
justinchubyCopilot
andauthored
Fix Gemma4 scale-free V norm FP16 overflow on CUDA (#253)
Gemma4's parameterless V normalization (`v / sqrt(mean(v²) + ε)`) squared FP16 values directly. V projection outputs reach ~888, and 888² overflows FP16 max (65504) → inf → 0. This caused all-zero V outputs on CUDA (CPU uses FP32 internally). **Fix**: Cast to FP32 before squaring, compute full RMSNorm in FP32, CastLike back. **Result**: F16 CUDA 151.5 tok/s on H200 (was NaN). --------- Signed-off-by: Justin Chu <justinchu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c1daeaa commit a0b7ce1

2 files changed

Lines changed: 72 additions & 21 deletions

File tree

src/mobius/models/gemma4.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -100,27 +100,29 @@ class _Gemma4ScaleFreeRMSNorm(nn.Module):
100100
Used for V norms in the vision encoder, the vision projector pre-norm,
101101
and the audio pre-projection norm.
102102
103-
Implemented as manual ``x / sqrt(mean(x²) + ε)`` rather than
104-
``op.RMSNormalization`` to prevent ORT's graph optimizer from fusing
105-
an upstream ``Add(bias)`` into ``SkipSimplifiedLayerNormalization``,
106-
which requires the skip tensor to have the same shape as the input
107-
(failing when the bias is 1D or has mismatched temporal dimension).
103+
Uses ``op.RMSNormalization`` with ``stash_type=1`` (float32 accumulation)
104+
to handle FP16 overflow: values > 256 squared exceed the FP16 max (65504).
108105
"""
109106

110107
def __init__(self, dim: int, eps: float = 1e-6):
111108
super().__init__()
112109
self.dim = dim
113110
self.eps = eps
111+
# Constant all-ones scale (not a learnable parameter from HF).
112+
self.weight = nn.Parameter([dim], data=ir.Tensor(np.ones(dim, dtype=np.float32)))
114113

115114
def forward(self, op: builder.OpBuilder, hidden_states: ir.Value) -> ir.Value:
116-
# Manual RMSNorm: x / sqrt(mean(x²) + ε), scale = 1.0 (scale-free).
117-
# Using primitive ops avoids ORT's SkipLayerNorm fusion pattern which
118-
# would corrupt the skip shape when an upstream Add uses a 1D bias.
119-
square = op.Mul(hidden_states, hidden_states)
120-
mean_sq = op.ReduceMean(square, op.Constant(value_ints=[-1]), keepdims=1)
121-
eps = op.CastLike(op.Constant(value_float=self.eps), mean_sq)
122-
rms = op.Sqrt(op.Add(mean_sq, eps))
123-
return op.Div(hidden_states, rms)
115+
# stash_type=1 means accumulate variance in float32, avoiding
116+
# FP16 overflow when squaring large values.
117+
# CastLike ensures the weight matches the input dtype.
118+
scale = op.CastLike(self.weight, hidden_states)
119+
return op.RMSNormalization(
120+
hidden_states,
121+
scale,
122+
axis=-1,
123+
epsilon=self.eps,
124+
stash_type=1,
125+
)
124126

125127

126128
# ---------------------------------------------------------------------------
@@ -773,16 +775,18 @@ def forward(
773775
value_raw = key_raw
774776
else:
775777
value_raw = self.v_proj(op, hidden_states)
776-
# Parameterless per-head V normalisation
778+
# Parameterless per-head V normalisation (FP32 accumulation to
779+
# prevent FP16 overflow when squaring values > 256).
777780
value_states = op.Reshape(
778781
value_raw,
779782
op.Constant(value_ints=[0, 0, self.num_key_value_heads, self.head_dim]),
780783
)
781-
sq = op.Mul(value_states, value_states)
784+
v_f32 = op.Cast(value_states, to=ir.DataType.FLOAT)
785+
sq = op.Mul(v_f32, v_f32)
782786
mean_sq = op.ReduceMean(sq, [-1], keepdims=1)
783787
eps = op.Constant(value_floats=[self._v_norm_eps])
784-
rms = op.Sqrt(op.Add(mean_sq, op.CastLike(eps, mean_sq)))
785-
value_states = op.Div(value_states, rms)
788+
rms = op.Sqrt(op.Add(mean_sq, eps))
789+
value_states = op.CastLike(op.Div(v_f32, rms), value_states)
786790
value_states = op.Reshape(value_states, [0, 0, -1])
787791

788792
# Build GQA attributes
@@ -846,19 +850,21 @@ def forward(
846850
value_raw = key_raw
847851
else:
848852
value_raw = self.v_proj(op, hidden_states)
849-
# Parameterless per-head V normalisation
853+
# Parameterless per-head V normalisation (FP32 accumulation to
854+
# prevent FP16 overflow when squaring values > 256).
850855
value_states = op.Reshape(
851856
value_raw,
852857
op.Constant(value_ints=[0, 0, self.num_key_value_heads, self.head_dim]),
853858
)
854-
sq = op.Mul(value_states, value_states)
859+
v_f32 = op.Cast(value_states, to=ir.DataType.FLOAT)
860+
sq = op.Mul(v_f32, v_f32)
855861
mean_sq = op.ReduceMean(sq, [-1], keepdims=1)
856862
# Use op.Constant to create a 1D tensor node (not a scalar initializer).
857863
# Scalar Python floats use a type-keyed cache that can fail when upstream
858864
# type information is missing (e.g., after custom ops like com.microsoft.MoE).
859865
eps = op.Constant(value_floats=[self._v_norm_eps])
860-
rms = op.Sqrt(op.Add(mean_sq, op.CastLike(eps, mean_sq)))
861-
value_states = op.Div(value_states, rms)
866+
rms = op.Sqrt(op.Add(mean_sq, eps))
867+
value_states = op.CastLike(op.Div(v_f32, rms), value_states)
862868
value_states = op.Reshape(value_states, [0, 0, -1])
863869

864870
attn_output, present_key, present_value = _apply_attention(

src/mobius/models/gemma4_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import onnx_ir as ir
89
import torch
910

1011
from mobius._configs import Gemma4Config
@@ -117,3 +118,47 @@ def test_per_expert_scale_not_folded(self):
117118
key = "decoder.model.layers.0.router.per_expert_scale"
118119
assert key in result
119120
assert torch.allclose(result[key], torch.ones(4))
121+
122+
123+
class TestScaleFreeRMSNormOverflow:
124+
"""V norm should handle FP16 overflow from squaring large values."""
125+
126+
def test_vnorm_fp16_no_nan(self):
127+
"""Values ~888 overflow FP16 when squared (888²=788K > 65504).
128+
129+
The scale-free RMSNorm must use stash_type=1 (float32 accumulation)
130+
to avoid inf/NaN from the variance computation.
131+
"""
132+
import numpy as np
133+
134+
from mobius._testing.ort_inference import OnnxModelSession
135+
from mobius.models.gemma4 import _Gemma4ScaleFreeRMSNorm
136+
137+
dim = 64
138+
norm = _Gemma4ScaleFreeRMSNorm(dim, eps=1e-6)
139+
140+
# Build a minimal ONNX graph for the norm
141+
from mobius.tasks._base import _make_graph, _make_model
142+
143+
graph, builder = _make_graph()
144+
op = builder.op
145+
x = builder.input("x", dtype=ir.DataType.FLOAT16, shape=[1, 4, dim])
146+
y = norm(op, x)
147+
builder.add_output(y, "y")
148+
model = _make_model(graph)
149+
150+
session = OnnxModelSession(model, device="cpu")
151+
152+
# Values that overflow FP16 when squared: 888² = 788,544 > 65504
153+
test_input = np.full((1, 4, dim), 888.0, dtype=np.float16)
154+
result = session.run({"x": test_input})
155+
output = result["y"]
156+
157+
assert not np.any(np.isnan(output)), "V norm produced NaN for input 888"
158+
assert not np.any(np.isinf(output)), "V norm produced Inf for input 888"
159+
# RMSNorm of a constant vector: x/rms(x) = sign(x) ≈ 1.0
160+
np.testing.assert_allclose(
161+
output.astype(np.float32),
162+
np.ones_like(output, dtype=np.float32),
163+
atol=0.01,
164+
)

0 commit comments

Comments
 (0)