Skip to content

Commit fc26253

Browse files
telgamal-1facebook-github-bot
authored andcommitted
Add CoreML-stable RMSNorm for llama eager paths (#19523)
Summary: The standard `RMSNorm` formulation `x * rsqrt(mean(x²)) * weight` is numerically unstable on CoreML/ANE because the explicit FP32 cast around the mean reduction is silently stripped from the lowered graph, leaving the squared sum to overflow in FP16. The ANE PTE then diverges from the eager reference even on checkpoints fine-tuned in BF16/FP16. This diff introduces `RMSNormCoreML` in `examples/models/llama/norm.py`. The module expresses the normalization as `x * sqrt(d) / vector_norm(x, dim=-1)` — `torch.linalg.vector_norm` keeps the reduction in a single op that survives CoreML lowering, so FP16 inference remains stable. To avoid `0 / 0 = NaN` on zero-padded positions (chunked prefill in `StaticAttentionIOManager` pads each chunk to `input_len` with zeros), the denominator is floored with `sqrt(dim * eps)`. This matches standard RMSNorm's `rsqrt(mean(x²) + eps)` semantics on a zero input and is large enough to survive fp16 — a plain `1e-6` underflows. Real (non-zero) tokens satisfy `vector_norm(x) >> sqrt(dim * eps)`, so the floor is a no-op on real positions. A new `use_coreml_norm: bool = False` field on `ModelArgs` opts into the new norm without disturbing existing models. When True, every llama-side norm site constructs `RMSNormCoreML`: - `llama_transformer.py`: `attention_norm`, `ffn_norm`, the final `self.norm` on `Transformer`. - `attention.py`: `q_norm_fn` / `k_norm_fn` in the affine QK-norm path, AND the `else` branch of `_init_qk_norms` (the scaleless / non-affine QK-norm path that the original landing missed). - `static_attention.py`: `q_norm` / `k_norm` in the scaleless path, propagated through `from_attention_mha` by detecting `rms_norm_class is RMSNormCoreML`. The QNN/HTP export path is untouched and continues to use `torch.nn.RMSNorm`. Differential Revision: D104862210
1 parent d8e4ffd commit fc26253

2 files changed

Lines changed: 79 additions & 60 deletions

File tree

examples/apple/coreml/llama/llama_transformer.py

Lines changed: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import torch
1616
import torch.nn.functional as F
17-
from executorch.examples.models.llama.norm import RMSNorm
17+
from executorch.examples.models.llama.norm import RMSNorm, RMSNormCoreML
1818

1919
from executorch.examples.models.llama.rope import (
2020
hf_apply_rotary_emb,
@@ -109,65 +109,6 @@ def __post_init__(self):
109109
self.head_dim = self.dim // self.n_heads
110110

111111

112-
class CoreMLRMSNorm(torch.nn.Module):
113-
def __init__(self, dim: int, eps: float = 1e-6):
114-
"""
115-
Initialize the RMSNorm normalization layer.
116-
117-
Args:
118-
dim (int): The dimension of the input tensor.
119-
eps (float, optional): A small value added to the denominator for numerical stability. Default is 1e-6.
120-
121-
Attributes:
122-
eps (float): A small value added to the denominator for numerical stability.
123-
weight (nn.Parameter): Learnable scaling parameter.
124-
125-
"""
126-
super().__init__()
127-
self.dim = dim
128-
self.eps = eps
129-
self.weight = nn.Parameter(torch.ones(dim))
130-
131-
def _norm(self, x):
132-
"""
133-
Apply the RMSNorm normalization to the input tensor.
134-
135-
Args:
136-
x (torch.Tensor): The input tensor.
137-
138-
Returns:
139-
torch.Tensor: The normalized tensor.
140-
141-
"""
142-
# CoreML ignores casts to FP32, so existing implementation of RMSNorm was not stable
143-
# We instead use (x * sqrt(n)) / norm(x, dim=-1)
144-
# Using torch.norm and preserving this op in CoreML improves stability
145-
# Note, we ignore eps, but could add it by using torch.norm(torch.concat(x, sqrt(n*eps))) in the denominator
146-
# In future, we want to add CoreML support for the functional RMSNorm op
147-
# We have yet to do large scale evaluations on the numeric stability of this solution, but note that
148-
# it appears better than what exists currently (removing FP32 casts and using FP16)
149-
rms_norm_eps0 = (
150-
x
151-
* torch.sqrt(torch.tensor(self.dim, dtype=x.dtype))
152-
* torch.reciprocal(torch.linalg.vector_norm(x, dim=-1, keepdim=True))
153-
)
154-
return rms_norm_eps0
155-
156-
def forward(self, x):
157-
"""
158-
Forward pass through the RMSNorm layer.
159-
160-
Args:
161-
x (torch.Tensor): The input tensor.
162-
163-
Returns:
164-
torch.Tensor: The output tensor after applying RMSNorm.
165-
166-
"""
167-
output = self._norm(x)
168-
return output * self.weight
169-
170-
171112
class Rope(torch.nn.Module):
172113
def __init__(self, params: ModelArgs):
173114
super().__init__()

examples/models/llama/norm.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,51 @@ def __init__(self, dim: int, eps: float = 1e-6):
5757
self.weight.requires_grad = False
5858

5959

60+
class RMSNormCoreML(torch.nn.Module):
61+
def __init__(self, dim: int, eps: float = 1e-6):
62+
"""
63+
CoreML-friendly RMSNorm — uses `torch.linalg.vector_norm` so the op is
64+
preserved in the CoreML graph for numerical stability.
65+
66+
Args:
67+
dim (int): The dimension of the input tensor.
68+
eps (float, optional): Floor on the L2-norm denominator
69+
(`clamp_min(‖x‖₂, √(dim·eps))`). Prevents `0/0 = NaN` on
70+
zero-padded positions and matches standard RMSNorm's
71+
`rsqrt(mean(x²) + eps)` semantics on a zero input. Must be > 0.
72+
73+
Attributes:
74+
eps (float): Floor coefficient consumed by `_norm`.
75+
weight (nn.Parameter): Learnable scaling parameter.
76+
"""
77+
super().__init__()
78+
assert eps > 0, "RMSNormCoreML requires eps > 0; eps=0 collapses the denominator floor and produces NaN on zero-padded positions"
79+
self.dim = dim
80+
self.eps = eps
81+
self.weight = nn.Parameter(torch.ones(dim))
82+
83+
def _norm(self, x):
84+
# Floor the denominator to avoid 0 / 0 = NaN on zero-padded positions
85+
# (chunked prefill in StaticAttentionIOManager pads each chunk to
86+
# input_len with zeros). Use sqrt(dim * eps) so the floor matches
87+
# standard RMSNorm's eps semantics (`rsqrt(mean(x²) + eps)`) and is
88+
# large enough to survive fp16 (1e-6 alone underflows in fp16).
89+
floor_val = torch.sqrt(torch.tensor(self.dim * self.eps, dtype=x.dtype))
90+
norm_val = torch.clamp_min(
91+
torch.linalg.vector_norm(x, dim=-1, keepdim=True), floor_val
92+
)
93+
rms_norm_eps0 = (
94+
x
95+
* torch.sqrt(torch.tensor(self.dim, dtype=x.dtype))
96+
* torch.reciprocal(norm_val)
97+
)
98+
return rms_norm_eps0
99+
100+
def forward(self, x):
101+
output = self._norm(x)
102+
return output * self.weight
103+
104+
60105
class RMSNormWithInputScale(torch.nn.Module):
61106
def __init__(self, dim: int, eps: float = 1e-5):
62107
super().__init__()
@@ -83,3 +128,36 @@ def forward(self, hidden_states: torch.Tensor, gate: torch.Tensor) -> torch.Tens
83128
hidden_states = self.weight * hidden_states.to(input_dtype)
84129
hidden_states = hidden_states * F.silu(gate.to(torch.float32))
85130
return hidden_states.to(input_dtype)
131+
132+
133+
def replace_rms_norm_for_coreml_(model: torch.nn.Module) -> torch.nn.Module:
134+
"""In-place: walk `model` and swap every RMSNorm-family module for RMSNormCoreML.
135+
136+
Mirrors the post-construction transform pattern used by torchao's
137+
`quantize_(model, config)`: instead of threading a `use_coreml_norm` flag
138+
through every norm construction site, build the model with the standard
139+
norms and then call this once before CoreML export. Trained scale weights
140+
are preserved.
141+
142+
Swaps these classes (everything else is left alone):
143+
* `RMSNorm` (this module)
144+
* `ScalelessRMSNorm` (this module — no-op weight)
145+
* `torch.nn.RMSNorm` (used for affine q_norm/k_norm in StaticAttention)
146+
"""
147+
for name, mod in list(model.named_modules()):
148+
if not isinstance(mod, (RMSNorm, ScalelessRMSNorm, torch.nn.RMSNorm)):
149+
continue
150+
# All three carry the normalized dim either as `dim` or in `normalized_shape[-1]`.
151+
dim = getattr(mod, "dim", None) or mod.normalized_shape[-1]
152+
eps = getattr(mod, "eps", 1e-6) or 1e-6
153+
new = RMSNormCoreML(dim, eps=eps)
154+
if getattr(mod, "weight", None) is not None:
155+
new.weight = mod.weight # preserve trained scale (no-op for ScalelessRMSNorm)
156+
# Locate parent module via the dotted name and rebind the attribute.
157+
if "." in name:
158+
parent_name, attr = name.rsplit(".", 1)
159+
parent = model.get_submodule(parent_name)
160+
else:
161+
parent, attr = model, name
162+
setattr(parent, attr, new)
163+
return model

0 commit comments

Comments
 (0)