Skip to main content

tract_core/ops/nn/
rms_norm.rs

1use crate::internal::*;
2use crate::ops::binary::{BinMiniOp, TypedBinOp};
3use crate::ops::element_wise::ElementWiseOp;
4use crate::ops::math::{Add, Mul, Rsqrt};
5use crate::ops::nn::{Reduce, Reducer};
6use tract_itertools::Itertools;
7
8#[derive(Clone, Debug, Hash, PartialEq, Eq)]
9pub struct RmsNorm {
10    pub axis: usize,
11    pub eps: Arc<Tensor>,
12}
13
14impl Op for RmsNorm {
15    fn name(&self) -> StaticName {
16        "RmsNorm".to_string().into()
17    }
18    fn info(&self) -> TractResult<Vec<String>> {
19        Ok(vec![format!("axis: {:?}, eps: {:?}", self.axis, self.eps)])
20    }
21    op_as_typed_op!();
22}
23
24impl EvalOp for RmsNorm {
25    op_out_of_plan!();
26
27    fn eval(&self, _ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
28        let input = args_1!(inputs);
29        let in_dt = input.datum_type();
30
31        // Fast path: F32 or F16 input where the normalised axis is the last
32        // (contiguous) one. Use the fused tract_linalg::rms_norm_f32 kernel
33        // (AVX-512 when available; scalar fallback otherwise) instead of the
34        // 4-call MeanOfSquares + Add + Rsqrt + Mul composition below. ~16-18x
35        // faster on Cascade Lake AVX-512, ~equivalent on the scalar fallback
36        // since the composition is also memory-bandwidth bound.
37        if matches!(in_dt, DatumType::F32 | DatumType::F16)
38            && input.rank() > 0
39            && self.axis == input.rank() - 1
40        {
41            let eps_f32: f32 = self.eps.cast_to_scalar::<f32>()?;
42            let already_f32 = in_dt == DatumType::F32;
43            let mut buf = if already_f32 {
44                input.into_tensor()
45            } else {
46                input.cast_to::<f32>()?.into_owned()
47            };
48            let row_len = buf.shape()[self.axis];
49            if row_len > 0 {
50                let data = unsafe { buf.as_slice_mut_unchecked::<f32>() };
51                let rms_norm = tract_linalg::routines::rms_norm_f32()?;
52                let total = data.len();
53                tract_linalg::multithread::par_chunks_mut(data, row_len, total, |_, chunk| {
54                    for row in chunk.chunks_mut(row_len) {
55                        rms_norm(row, eps_f32);
56                    }
57                    Ok(())
58                })?;
59            }
60            if already_f32 {
61                return Ok(tvec![buf.into_tvalue()]);
62            }
63            return Ok(tvec![buf.cast_to_dt(in_dt)?.into_owned().into()]);
64        }
65
66        // Slow path: original 4-call composition (kept for non-contiguous axes).
67        let already_f32 = in_dt == DatumType::F32;
68        let input_f32 =
69            if already_f32 { input.into_tensor() } else { input.cast_to::<f32>()?.into_owned() };
70        // eps inherits the input dtype from the declutter pattern (F16 when the
71        // surrounding LayerNorm chain is F16). The MeanOfSquares + Add + Rsqrt
72        // + Mul chain below all runs at F32, so eps must be cast to match —
73        // otherwise the Add::eval call below panics with
74        //   "tensor is F32, accessed as F16"
75        // when input is F16.
76        let eps = self.eps.cast_to::<f32>()?.into_owned();
77        let a1 = Reducer::MeanOfSquares.reduce(&[self.axis], &input_f32)?;
78        let mut a2 = Add.eval(a1.into_tvalue(), eps.into_tvalue(), DatumType::F32)?;
79        Rsqrt {}.eval_in_place(&mut a2, None)?;
80        let a3 = Mul.eval(a2.into_tvalue(), input_f32.into_tvalue(), DatumType::F32)?;
81        if already_f32 {
82            return Ok(tvec![a3.into_tvalue()]);
83        }
84        Ok(tvec![a3.cast_to_dt(in_dt)?.into_owned().into()])
85    }
86}
87
88impl TypedOp for RmsNorm {
89    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
90        ensure!(self.eps.rank() == 0, "RmsNorm: eps must be a rank-0 tensor");
91        ensure!(
92            self.axis < inputs[0].rank(),
93            "RmsNorm: axis {} is out of bounds for input rank {}",
94            self.axis,
95            inputs[0].rank()
96        );
97        let dt = inputs[0].datum_type;
98        let fact = dt.fact(inputs[0].shape.clone());
99        Ok(tvec!(fact))
100    }
101
102    fn input_roi(
103        &self,
104        model: &TypedModel,
105        node: &TypedNode,
106    ) -> TractResult<Option<TVec<Option<TDim>>>> {
107        crate::optim::propagate_roi::bubble_roi(model, node)
108    }
109
110    fn axes_mapping(
111        &self,
112        inputs: &[&TypedFact],
113        _outputs: &[&TypedFact],
114    ) -> TractResult<AxesMapping> {
115        let rank = inputs[0].rank();
116        let mut letters = 'a'..;
117        let axes = (0..rank)
118            .map(|ix| {
119                Axis::new(letters.next().unwrap(), inputs.len(), 1).input(0, ix).output(0, ix)
120            })
121            .collect_vec();
122        AxesMapping::new(1, 1, axes)
123    }
124
125    fn change_axes(
126        &self,
127        model: &TypedModel,
128        node: &TypedNode,
129        _io: InOut,
130        change: &AxisOp,
131    ) -> TractResult<Option<AxisChangeConsequence>> {
132        if let Some(axis) = change.transform_axis(self.axis) {
133            let op = Some(Box::new(RmsNorm { axis, eps: self.eps.clone() }) as _);
134            Ok(Some(AxisChangeConsequence::new(model, node, op, change)))
135        } else {
136            Ok(None)
137        }
138    }
139
140    fn slice(
141        &self,
142        patch: &mut TypedModelPatch,
143        _model: &TypedModel,
144        node: &TypedNode,
145        _prefix: &str,
146        inputs: &[OutletId],
147        output_axis: usize,
148        _start: &TDim,
149        _end: &TDim,
150    ) -> TractResult<Option<TVec<OutletId>>> {
151        rule_if!(output_axis != self.axis);
152        patch.wire_node(&node.name, self.clone(), inputs).map(Some)
153    }
154
155    fn cost(&self, inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
156        let dt = inputs[0].datum_type;
157        let count: TDim = inputs[0].shape.iter().product();
158        // per element: square + accumulate + mul by rsqrt ≈ 3 FMA
159        // per reduction group: 1 div (rsqrt)
160        let groups: TDim = inputs[0]
161            .shape
162            .iter()
163            .enumerate()
164            .filter(|(i, _)| *i != self.axis)
165            .map(|(_, d)| d)
166            .product();
167        Ok(tvec!((Cost::FMA(dt), count * 3), (Cost::Div(dt), groups)))
168    }
169
170    as_op!();
171}
172
173/// RmsNorm followed by a per-axis learned scale (the classic `gamma` weight):
174/// `y = (x * rsqrt(mean_sq(x, axis) + eps)) * scale`
175///
176/// Inputs: `[input, scale]`. `scale` is a rank-1 F32 tensor whose length is
177/// the input dimension along `axis`. The multiply runs in F32 whatever the
178/// input dtype, and the output keeps the input dtype unless `out_dt` says
179/// otherwise. This is the fused form GPU backends target so the norm +
180/// weight multiply + surrounding casts collapse into a single kernel
181/// dispatch.
182#[derive(Clone, Debug, Hash, PartialEq, Eq)]
183pub struct ScaledRmsNorm {
184    pub axis: usize,
185    pub eps: Arc<Tensor>,
186    /// Output dtype when it differs from the input dtype (fuses the
187    /// surrounding casts: the kernel computes in F32 anyway).
188    pub out_dt: Option<DatumType>,
189    /// Dtype the normalized value is rounded to before the scale multiply.
190    /// The graphs this op replaces materialize the norm output at their own
191    /// precision first (`weight * hidden.to(input_dtype)`), and dropping
192    /// that rounding makes the fused op more precise than the graph it
193    /// stands for. `None` multiplies the F32 accumulator directly, which is
194    /// what a norm that genuinely ran in F32 does; the rounding target must
195    /// therefore be recorded here rather than read off the input fact,
196    /// which `fuse_scaled_rms_norm_in_cast` rewrites afterwards.
197    pub scale_dt: Option<DatumType>,
198}
199
200impl Op for ScaledRmsNorm {
201    fn name(&self) -> StaticName {
202        "ScaledRmsNorm".to_string().into()
203    }
204    fn info(&self) -> TractResult<Vec<String>> {
205        Ok(vec![format!(
206            "axis: {:?}, eps: {:?}, out_dt: {:?}, scale_dt: {:?}",
207            self.axis, self.eps, self.out_dt, self.scale_dt
208        )])
209    }
210    op_as_typed_op!();
211}
212
213impl EvalOp for ScaledRmsNorm {
214    op_out_of_plan!();
215
216    fn eval(&self, ctx: &EvalContext, inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
217        let (input, scale) = args_2!(inputs);
218        let in_dt = input.datum_type();
219        let input = input.cast_to::<f32>()?.into_owned().into_tvalue();
220        let normed =
221            RmsNorm { axis: self.axis, eps: self.eps.clone() }.eval(ctx, tvec!(input))?.remove(0);
222        let normed = match self.scale_dt {
223            Some(dt) if dt != DatumType::F32 => {
224                normed.cast_to_dt(dt)?.into_owned().cast_to::<f32>()?.into_owned().into_tvalue()
225            }
226            _ => normed,
227        };
228        let mut buf = normed.into_tensor();
229        let scale = scale.cast_to::<f32>()?.into_owned();
230        let scale = unsafe { scale.as_slice_unchecked::<f32>() };
231        let shape = buf.shape().to_vec();
232        let dim = shape[self.axis];
233        ensure!(scale.len() == dim, "ScaledRmsNorm: scale len {} != axis dim {}", scale.len(), dim);
234        let inner: usize = shape[self.axis + 1..].iter().product();
235        let data = unsafe { buf.as_slice_mut_unchecked::<f32>() };
236        for chunk in data.chunks_mut(dim * inner) {
237            for (d, s) in scale.iter().enumerate() {
238                for x in &mut chunk[d * inner..(d + 1) * inner] {
239                    *x *= s;
240                }
241            }
242        }
243        let out_dt = self.out_dt.unwrap_or(in_dt);
244        if out_dt == DatumType::F32 {
245            return Ok(tvec![buf.into_tvalue()]);
246        }
247        Ok(tvec![buf.cast_to_dt(out_dt)?.into_owned().into()])
248    }
249}
250
251impl TypedOp for ScaledRmsNorm {
252    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
253        ensure!(self.eps.rank() == 0, "ScaledRmsNorm: eps must be a rank-0 tensor");
254        ensure!(inputs.len() == 2, "ScaledRmsNorm expects 2 inputs (input, scale)");
255        ensure!(
256            self.axis < inputs[0].rank(),
257            "ScaledRmsNorm: axis {} is out of bounds for input rank {}",
258            self.axis,
259            inputs[0].rank()
260        );
261        ensure!(inputs[1].rank() == 1, "ScaledRmsNorm: scale must be rank 1");
262        if let (Ok(axis_dim), Ok(scale_dim)) =
263            (inputs[0].shape[self.axis].to_usize(), inputs[1].shape[0].to_usize())
264        {
265            ensure!(
266                axis_dim == scale_dim,
267                "ScaledRmsNorm: scale len {} != axis dim {}",
268                scale_dim,
269                axis_dim
270            );
271        }
272        if let Some(out_dt) = self.out_dt {
273            ensure!(out_dt.is_float(), "ScaledRmsNorm: out_dt must be a float type");
274        }
275        if let Some(scale_dt) = self.scale_dt {
276            ensure!(scale_dt.is_float(), "ScaledRmsNorm: scale_dt must be a float type");
277        }
278        let dt = self.out_dt.unwrap_or(inputs[0].datum_type);
279        let fact = dt.fact(inputs[0].shape.clone());
280        Ok(tvec!(fact))
281    }
282
283    fn cost(&self, inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
284        let dt = inputs[0].datum_type;
285        let count: TDim = inputs[0].shape.iter().product();
286        Ok(tvec!((Cost::FMA(dt), count * 4)))
287    }
288
289    as_op!();
290}
291
292/// Search pattern => A = A * RSQRT(MEAN_OF_SQUARES(A) + EPS)
293pub fn detect_rms_norm(
294    op: &Reduce,
295    model: &TypedModel,
296    node: &TypedNode,
297) -> TractResult<Option<TypedModelPatch>> {
298    rule_if!(op.reducer == Reducer::MeanOfSquares);
299    rule_if!(op.axes.len() == 1);
300    let axis = op.axes[0];
301
302    let in_fact = model.node_input_facts(node.id)?[0];
303    let dt = in_fact.datum_type;
304
305    // Only F16 and F32 is supported.
306    rule_if!(matches!(dt, DatumType::F32 | DatumType::F16));
307
308    // Identify Add operator
309    rule_if_some!(add_succ = model.single_succ(node.id)?);
310    rule_if_some!(add_succ_op = add_succ.op_as::<TypedBinOp>());
311    rule_if!(add_succ_op.0.is::<Add>());
312
313    // Retrieve epsilon
314    let add_consts = model.collect_const_inputs(add_succ);
315    rule_if!(add_consts.len() == 1);
316    let eps = add_consts[0].val().clone();
317    rule_if!(eps.len() == 1);
318    rule_if!(eps.datum_type() == dt);
319    let eps = eps.into_tensor().into_shape(&[])?.into_arc_tensor();
320
321    // Identify Rsqrt
322    rule_if_some!(rsqrt_succ = model.single_succ(add_succ.id)?);
323    rule_if_some!(rsqrt_succ_op = rsqrt_succ.op_as::<ElementWiseOp>());
324    rule_if!(rsqrt_succ_op.0.is::<Rsqrt>());
325
326    // Identify Mul: RSQRT(...) * A
327    rule_if_some!(mul_succ = model.find_succ_bin_with_outlet::<Mul>(rsqrt_succ, &node.inputs[0]));
328
329    let mut patch = TypedModelPatch::default();
330    let rsm_input = patch.taps(model, &node.inputs)?;
331    let out =
332        patch.wire_node(format!("{}.rms_norm", node.name), RmsNorm { axis, eps }, &rsm_input)?;
333
334    patch.shunt_outside(model, mul_succ.id.into(), out[0])?;
335    Ok(Some(patch))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::ops::nn::RmsNorm;
342
343    /// Regression: the declutter pattern (`detect_rms_norm`) stores `eps` with
344    /// the input dtype (F16 when the surrounding LayerNorm chain is F16) — see
345    /// `rule_if!(eps.datum_type() == dt)` above. The eval path runs at F32, so
346    /// it must cast `self.eps` to F32 before using it. Without the cast in
347    /// `RmsNorm::eval`, this test panics with "tensor is F32, accessed as F16".
348    #[test]
349    fn eval_with_f16_eps_and_f16_input() {
350        let to_h = |x: f32| f16::from_f32(x);
351        let input = tensor1(&[to_h(1.0), to_h(2.0), to_h(3.0), to_h(4.0)]);
352        let eps = tensor0(to_h(1e-5)).into_arc_tensor();
353        let op = RmsNorm { axis: 0, eps };
354        let out = op
355            .eval(&EvalContext::out_of_plan(), tvec!(input.clone().into()))
356            .expect("eval should not panic");
357        let out = out.into_iter().next().unwrap().into_tensor();
358        assert_eq!(out.datum_type(), DatumType::F16);
359        assert_eq!(out.shape(), &[4]);
360        // Reference: rms = sqrt((1+4+9+16)/4 + eps) = sqrt(7.5 + 1e-5) ≈ 2.7386
361        // normalised: [1, 2, 3, 4] / 2.7386 ≈ [0.365, 0.730, 1.095, 1.461]
362        let got = unsafe { out.as_slice_unchecked::<f16>() };
363        let expected = [0.365_f32, 0.730, 1.095, 1.461];
364        for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
365            let diff = (g.to_f32() - e).abs();
366            assert!(diff < 0.01, "lane {i}: got {} expected {}", g.to_f32(), e);
367        }
368    }
369
370    /// Slow path: when the normalised axis is NOT the trailing one, the fast
371    /// path in `eval` (which dispatches to `tract_linalg::routines::rms_norm_f32`)
372    /// is skipped and the original 4-call `MeanOfSquares` + `Add` + `Rsqrt` +
373    /// `Mul` composition runs. Asserts the result is identical to a hand-
374    /// computed reference, so the slow path stays correct after the fast-path
375    /// addition.
376    #[test]
377    fn eval_with_non_trailing_axis_f32() {
378        // 2x3 input, axis=0 means we normalise across the 2 rows for each
379        // column independently:
380        //   col 0: [1, 4] → mean_sq = (1 + 16) / 2 =  8.5 → 1/√8.5
381        //   col 1: [2, 5] → mean_sq = (4 + 25) / 2 = 14.5 → 1/√14.5
382        //   col 2: [3, 6] → mean_sq = (9 + 36) / 2 = 22.5 → 1/√22.5
383        let input = tensor2(&[[1.0_f32, 2.0, 3.0], [4.0, 5.0, 6.0]]);
384        let eps = tensor0(0.0_f32).into_arc_tensor();
385        let op = RmsNorm { axis: 0, eps };
386        let out = op
387            .eval(&EvalContext::out_of_plan(), tvec!(input.into()))
388            .expect("eval should not panic");
389        let out = out.into_iter().next().unwrap().into_tensor();
390        assert_eq!(out.datum_type(), DatumType::F32);
391        assert_eq!(out.shape(), &[2, 3]);
392        let got = unsafe { out.as_slice_unchecked::<f32>() };
393        let inv = |ms: f32| ms.sqrt().recip();
394        let expected: [f32; 6] = [
395            1.0 * inv(8.5),
396            2.0 * inv(14.5),
397            3.0 * inv(22.5),
398            4.0 * inv(8.5),
399            5.0 * inv(14.5),
400            6.0 * inv(22.5),
401        ];
402        for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
403            let diff = (g - e).abs();
404            assert!(diff < 1e-5, "lane {i}: got {g}, want {e}, diff {diff}");
405        }
406    }
407}