Skip to main content

ferrox_models/
norm.rs

1//! The normalisation at ONE site in a decoder: before the attention
2//! branch, before the FFN branch, or before the LM head.
3//!
4//! Three variants, because llama.cpp's `build_norm`
5//! (`llama-graph.cpp`) has three answers at those sites and ferrox used
6//! to have one. It takes a norm TYPE (`LLM_NORM` = LayerNorm,
7//! `LLM_NORM_RMS` = RMSNorm) and a weight that may be null, and applies
8//! the multiply only `if (mw)`. Everything below is a reading of that
9//! function and of the three graphs that reach its corners.
10//!
11//! ```text
12//! // the ordinary case, and nearly every architecture on the generic path
13//! ffn_inp = x       + attn(rms(x, attn_norm))
14//! out     = ffn_inp + ffn(rms(ffn_inp, ffn_norm))
15//! ```
16//!
17//! Gemma-2 added a *sandwich*: the same two pre-norms, plus a norm on
18//! each branch's OUTPUT before its residual add. ferrox has carried
19//! those two as [`crate::decoder::AttnWeights::post_attn_norm`] and
20//! [`crate::decoder::AttnWeights::post_ffn_norm`] for a long time, and
21//! they are not this type -- they are `Option<Vec<f32>>` RMSNorms, and
22//! no architecture has ever wanted anything else there.
23//!
24//! # [`NormOp::None`]: `olmo2` and `exaone4`
25//!
26//! **The sandwich with the bread taken off.** They have the two
27//! post-norms and NO pre-norms at all, and both sublayers read the raw
28//! residual:
29//!
30//! ```text
31//! ffn_inp = x       + post_attn_norm(attn(x))
32//! out     = ffn_inp + post_ffn_norm(ffn(ffn_inp))
33//! ```
34//!
35//! That claim is a reading of both files, not a family resemblance:
36//!
37//! | | `src/models/olmo2.cpp` | `src/models/exaone4.cpp` |
38//! |---|---|---|
39//! | per-layer norms created | `attn_q_norm`, `attn_k_norm`, `attn_post_norm`, `ffn_post_norm` (:45-52) | `attn_post_norm`, `attn_q_norm`, `attn_k_norm`, `ffn_post_norm` (:60-67) |
40//! | `attn_norm` | absent | absent |
41//! | `ffn_norm` | absent | absent |
42//! | Q/K/V read | `cur = inpL` (:92) | `cur = inpL` (:118) |
43//! | attention output | `build_norm(cur, attn_post_norm)` (:160-163) | `build_norm(cur, attn_post_norm)` (:152) |
44//! | `ffn_inp` | `add(cur, inpSA)` (:165) | `add(cur, inpSA)` (:155) |
45//! | FFN input | `build_ffn(ffn_inp, ...)` (:169) | `build_ffn(ffn_inp, ...)` (:159) |
46//! | FFN output | `build_norm(cur, ffn_post_norm)` (:177-179) | `build_norm(cur, ffn_post_norm)` (:166) |
47//! | residual | `add(cur, ffn_inp)` (:182) | `add(cur, ffn_inp)` (:169) |
48//!
49//! Line for line the same graph. So the two rows share ONE
50//! implementation -- this module -- rather than getting one arm each.
51//! What they do NOT share is their QK-norm style (`olmo2` norms the 2-D
52//! projection over its whole width, `exaone4` per head after
53//! `build_qkv` has reshaped), which is why each still needs its own
54//! fixture: `tests/post_norm_only_graphs.rs`.
55//!
56//! # [`NormOp::LayerNormNoParams`]: `olmo`, and only `olmo`
57//!
58//! OLMo-1 is a THIRD shape, and it is not about the residual at all.
59//! `src/models/olmo.cpp:27-35` creates Q/K/V, `attn_output` and
60//! gate/up/down and **not one norm tensor** -- no `attn_norm`, no
61//! `ffn_norm`, no `output_norm` -- and its graph normalises at all
62//! three sites with a null weight AND a null bias:
63//!
64//! ```text
65//! // olmo.cpp:65-67, :104-106, :128-130
66//! cur = build_norm(inpL, NULL, NULL, LLM_NORM, il);
67//! ```
68//!
69//! `LLM_NORM` is `ggml_norm` (`llama-graph.cpp`'s `build_norm`), which
70//! subtracts the mean and divides by the standard deviation
71//! (`ggml/src/ggml-cpu/ops.cpp:3716-3745`); with both weight and bias
72//! null, `build_norm` does nothing further. So it is a pre-norm layer
73//! like `llama`, with a different norm FUNCTION and no parameters at
74//! all. `NormOp::None` is no help here and neither is `NormOp::Rms`.
75//!
76//! **This is the only architecture in llama.cpp that does it.** Scanned
77//! over every `build_norm` call in all 140 `src/models/*.cpp` graphs,
78//! extracting the weight argument: three calls pass a null weight to
79//! `LLM_NORM`, and all three are `olmo.cpp`. (`talkie.cpp` passes a null
80//! weight to `LLM_NORM_RMS` at five sites, which is a different
81//! function and a different row.) So this variant closes exactly one
82//! refusal and the hoped-for shared cause is not there -- see
83//! `capability::NON_PARAMETRIC_LAYER_NORM`, which says so where the next
84//! person will look.
85//!
86//! # Why this is a type and not a `bool` on `ModelConfig`
87//!
88//! The RMSNorm weights are handed to fused Metal kernels that apply the
89//! norm INSIDE the kernel (`PrefillDenseLayerMetal::attn_norm_w`,
90//! `MoeLayerMetal::ffn_norm_w`, `launch_decode_dense_layer`). A flag on
91//! the config would leave every one of those launches free to keep
92//! reading a `&[f32]` that no longer means anything, and nothing would
93//! fail: that is precisely this repo's dominant bug shape, two
94//! structures that must agree with nothing enforcing it, and it is how
95//! `post_attn_norm` was lost from a decode path once already.
96//!
97//! Making the slot an enum instead means a fused launch cannot compile
98//! until it has said what it does when there is no weight to hand over.
99//! [`NormOp::rms_weights`] returns `None` for BOTH non-RMS variants, and
100//! every GPU call site turns that into a fall-back to the host body,
101//! which computes the right thing. The disagreement is a type error
102//! rather than a silent wrong answer. `Decoder::final_norm` is this type
103//! for the same reason: `olmo` is the first architecture whose FINAL
104//! norm is not an RMSNorm either, and the fused stacks that fold
105//! `final_norm + lm_head + argmax` had `Some(&self.final_norm)` written
106//! into them unconditionally.
107
108use ferrox_core::matmul::rms_norm;
109
110/// The normalisation applied at one norm site.
111#[derive(Debug, Clone, PartialEq)]
112pub enum NormOp {
113    /// RMSNorm with these learned weights. Every architecture on the
114    /// generic path except the two families below.
115    Rms(Vec<f32>),
116    /// Non-parametric LayerNorm: subtract the mean, divide by the
117    /// standard deviation, no learned weight and no bias.
118    ///
119    /// `olmo` (OLMo-1) and nothing else in llama.cpp. There is no
120    /// `LayerNorm(Vec<f32>)` beside this because no architecture ferrox
121    /// admits needs one: the weighted-LayerNorm rows (`dbrx`,
122    /// `nemotron`, `orion`, `stablelm`, ...) all refuse for other
123    /// reasons too, and a variant with no caller is a variant that
124    /// silently rots.
125    LayerNormNoParams,
126    /// No norm at all: the branch reads the raw residual.
127    ///
128    /// `olmo2` and `exaone4`. NOT "an RMSNorm whose weights are all
129    /// ones" -- that would still divide by the RMS of the residual, and
130    /// the whole point of this variant is that nothing is divided.
131    None,
132}
133
134impl NormOp {
135    /// The site's output: `rms_norm(x, w, eps)`, the non-parametric
136    /// LayerNorm, or `x` itself.
137    ///
138    /// Returns an owned vector in every arm, which is what every caller
139    /// already had: `rms_norm` allocates too.
140    ///
141    /// `eps` is `ModelConfig::rms_norm_eps`, which for a LayerNorm
142    /// architecture is read from `{arch}.attention.layer_norm_epsilon`
143    /// -- llama.cpp's `f_norm_eps` rather than `f_norm_rms_eps`. The two
144    /// are one field here because no architecture reads both keys, and
145    /// `loader.rs` already accepted either spelling into that field
146    /// before this variant existed.
147    pub fn apply(&self, x: &[f32], eps: f32) -> Vec<f32> {
148        match self {
149            Self::Rms(w) => rms_norm(x, w, eps),
150            Self::LayerNormNoParams => layer_norm_no_params(x, eps),
151            Self::None => x.to_vec(),
152        }
153    }
154
155    /// The weights a fused GPU kernel needs, or `None` when this site
156    /// has no RMSNorm weights and the kernel therefore must not run.
157    ///
158    /// Every fused Metal launch that bakes an RMSNorm into its kernel
159    /// goes through here and falls back to the host body on `None`.
160    /// See the module docs for why that is a `?` and not a comment.
161    pub fn rms_weights(&self) -> Option<&[f32]> {
162        match self {
163            Self::Rms(w) => Some(w),
164            Self::LayerNormNoParams | Self::None => None,
165        }
166    }
167}
168
169/// `(x - mean) / sqrt(var + eps)`, with the BIASED variance.
170///
171/// `ggml_compute_forward_norm_f32` (`ggml/src/ggml-cpu/ops.cpp:3716-3745`)
172/// divides the sum of squared deviations by `ne00`, not by `ne00 - 1`.
173/// Bessel's correction on a 4096-wide hidden state is a factor of
174/// 1.00012, which is far too small to fail a smoke test and far too
175/// large to be right.
176fn layer_norm_no_params(x: &[f32], eps: f32) -> Vec<f32> {
177    let n = x.len() as f32;
178    debug_assert!(n > 0.0, "a norm site with no elements");
179    let mean = x.iter().sum::<f32>() / n;
180    // The deviations are computed once and reused, which is also what
181    // ggml does: it writes `x - mean` into the destination and takes
182    // the variance from there.
183    let mut out: Vec<f32> = x.iter().map(|v| v - mean).collect();
184    let var = out.iter().map(|d| d * d).sum::<f32>() / n;
185    let scale = 1.0 / (var + eps).sqrt();
186    for v in out.iter_mut() {
187        *v *= scale;
188    }
189    out
190}
191
192// There is deliberately no `is_present()` beside `rms_weights()`.
193// "Does this site norm?" and "what weights does the kernel get?" are
194// the same fact for a fused kernel, and two spellings of one fact is
195// the shape this repo keeps shipping bugs in: `rms_weights().is_some()`
196// is the only way to ask.
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    /// `NormOp::None` is the identity, and an all-ones RMSNorm is not.
203    ///
204    /// The tempting shortcut for `olmo2` / `exaone4` was to load a
205    /// vector of ones into the existing slot and change nothing else.
206    /// It is wrong by exactly the RMS scale factor, which for a residual
207    /// with any magnitude at all is not close to 1. This test is what
208    /// stops somebody re-discovering that as a "simplification".
209    #[test]
210    fn no_norm_is_the_identity_and_an_all_ones_rmsnorm_is_not() {
211        let x = vec![3.0f32, -4.0, 12.0, 0.5];
212        let eps = 1e-5;
213
214        assert_eq!(NormOp::None.apply(&x, eps), x);
215
216        let ones = NormOp::Rms(vec![1.0; x.len()]);
217        let normed = ones.apply(&x, eps);
218        let worst = x
219            .iter()
220            .zip(normed.iter())
221            .map(|(a, b)| (a - b).abs())
222            .fold(0f32, f32::max);
223        assert!(
224            worst > 1.0,
225            "an all-ones RMSNorm moved the vector by only {worst}; if it were the \
226             identity the post-norm-only topology would not need a variant at all"
227        );
228    }
229
230    /// The non-parametric LayerNorm is not the all-ones RMSNorm either,
231    /// and the difference is the MEAN.
232    ///
233    /// This is the shortcut somebody will reach for next: "OLMo-1 just
234    /// has no norm weights, so load ones". On a vector with a non-zero
235    /// mean the two differ in every element, and the assertion below
236    /// measures that rather than trusting it -- a centred input would
237    /// make them agree and would prove nothing.
238    #[test]
239    fn the_layer_norm_subtracts_the_mean_and_an_all_ones_rmsnorm_does_not() {
240        let x = vec![3.0f32, -4.0, 12.0, 0.5];
241        let eps = 1e-5;
242        let ln = NormOp::LayerNormNoParams.apply(&x, eps);
243        let rms = NormOp::Rms(vec![1.0; x.len()]).apply(&x, eps);
244
245        let mean: f32 = x.iter().sum::<f32>() / x.len() as f32;
246        assert!(mean.abs() > 1.0, "the input must not be centred: {mean}");
247
248        let out_mean: f32 = ln.iter().sum::<f32>() / ln.len() as f32;
249        assert!(
250            out_mean.abs() < 1e-5,
251            "a LayerNorm's output is centred; got mean {out_mean}"
252        );
253
254        let worst = ln
255            .iter()
256            .zip(rms.iter())
257            .map(|(a, b)| (a - b).abs())
258            .fold(0f32, f32::max);
259        assert!(
260            worst > 0.1,
261            "the two norms differ by only {worst}; an all-ones RMSNorm would then be a \
262             legitimate stand-in for OLMo-1's norm and this variant would be decoration"
263        );
264    }
265
266    /// The variance is BIASED (divide by n), which is ggml's.
267    ///
268    /// Checked against arithmetic written out here rather than against
269    /// the implementation: the two spellings differ by sqrt(n/(n-1)),
270    /// which on this 4-element vector is 1.155 and on a real hidden
271    /// state is 1.0001 -- big enough to be wrong, small enough that no
272    /// end-to-end smoke test would notice.
273    #[test]
274    fn the_variance_is_the_biased_one_ggml_uses() {
275        let x = [1.0f32, 2.0, 3.0, 10.0];
276        let got = NormOp::LayerNormNoParams.apply(&x, 0.0);
277
278        let n = x.len() as f64;
279        let mean = x.iter().map(|v| *v as f64).sum::<f64>() / n;
280        let var = x.iter().map(|v| (*v as f64 - mean).powi(2)).sum::<f64>() / n;
281        let want: Vec<f32> = x
282            .iter()
283            .map(|v| ((*v as f64 - mean) / var.sqrt()) as f32)
284            .collect();
285
286        for (g, w) in got.iter().zip(want.iter()) {
287            assert!((g - w).abs() < 1e-5, "got {got:?}, want {want:?}");
288        }
289
290        // ... and the SAMPLE variance would be visibly different here,
291        // so this test can tell them apart.
292        let sample = x.iter().map(|v| (*v as f64 - mean).powi(2)).sum::<f64>() / (n - 1.0);
293        let worst = got
294            .iter()
295            .zip(x.iter())
296            .map(|(g, v)| (g - ((*v as f64 - mean) / sample.sqrt()) as f32).abs())
297            .fold(0f32, f32::max);
298        assert!(worst > 0.1, "the two variances differ by only {worst}");
299    }
300
301    /// A fused GPU launch cannot be handed weights that do not exist,
302    /// from EITHER non-RMS variant.
303    #[test]
304    fn only_the_rms_variant_offers_weights_to_a_fused_kernel() {
305        assert_eq!(
306            NormOp::Rms(vec![2.0, 3.0]).rms_weights(),
307            Some(&[2.0f32, 3.0][..])
308        );
309        assert_eq!(NormOp::None.rms_weights(), None);
310        assert_eq!(NormOp::LayerNormNoParams.rms_weights(), None);
311    }
312}