Skip to main content

frink_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 frink 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. frink 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 155 `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, and since the 2026-09-19 pin move so
82//! do `hrm-text.cpp` at three sites and `muse-glimmer.cpp` at one --
83//! the RMS row grew, the LayerNorm row did not.) So this variant
84//! closes exactly one refusal and the hoped-for shared cause is not
85//! there -- see
86//! `capability::NON_PARAMETRIC_LAYER_NORM`, which says so where the next
87//! person will look.
88//!
89//! # [`NormOp::LayerNorm`]: `dbrx`, the row that gave the variant a caller
90//!
91//! The LayerNorm FUNCTION is shared more widely than the parameterless
92//! corner: `dbrx` and the `nemotron` / `orion` / `stablelm` /
93//! `codeshell` / `jais2` / `starcoder` / `starcoder2` / `phimoe` group
94//! all normalise with `LLM_NORM` and a learned weight. This variant was
95//! deliberately NOT written beside `LayerNormNoParams`, because at that
96//! point every one of those rows refused for more than the norm, and a
97//! variant with no caller silently rots. `dbrx` is the caller:
98//! `src/models/dbrx.cpp:69-71`, `:110-112` and `:140-142` are
99//! `build_norm(x, w, NULL, LLM_NORM, il)` -- weight, no bias -- and its
100//! other two blockers, `{arch}.attention.clamp_kqv` and a pre-FFN norm
101//! stored as `blk.N.attn_output_norm`, took one implementation each
102//! (`crate::clamp_kqv`, `crate::norm_sites`).
103//!
104//! **Weight but no bias, on purpose.** Every other row in that group
105//! creates `*_norm.bias` as REQUIRED and `build_norm` adds it after the
106//! multiply. That is a fourth variant, `LayerNorm(w, b)`, and it is
107//! absent for the reason this one was absent before `dbrx`: no admitted
108//! row calls it. [`NormFunction`] is where the choice is made per
109//! architecture; a bias means a variant there and at every match on
110//! this enum, which is the compile error that is wanted.
111//!
112//! # Why this is a type and not a `bool` on `ModelConfig`
113//!
114//! The RMSNorm weights are handed to fused Metal kernels that apply the
115//! norm INSIDE the kernel (`PrefillDenseLayerMetal::attn_norm_w`,
116//! `MoeLayerMetal::ffn_norm_w`, `launch_decode_dense_layer`). A flag on
117//! the config would leave every one of those launches free to keep
118//! reading a `&[f32]` that no longer means anything, and nothing would
119//! fail: that is precisely this repo's dominant bug shape, two
120//! structures that must agree with nothing enforcing it, and it is how
121//! `post_attn_norm` was lost from a decode path once already.
122//!
123//! Making the slot an enum instead means a fused launch cannot compile
124//! until it has said what it does when there is no weight to hand over.
125//! [`NormOp::rms_weights`] returns `None` for BOTH non-RMS variants, and
126//! every GPU call site turns that into a fall-back to the host body,
127//! which computes the right thing. The disagreement is a type error
128//! rather than a silent wrong answer. `Decoder::final_norm` is this type
129//! for the same reason: `olmo` is the first architecture whose FINAL
130//! norm is not an RMSNorm either, and the fused stacks that fold
131//! `final_norm + lm_head + argmax` had `Some(&self.final_norm)` written
132//! into them unconditionally.
133
134use frink_core::matmul::rms_norm;
135
136/// The normalisation applied at one norm site.
137#[derive(Debug, Clone, PartialEq)]
138pub enum NormOp {
139    /// RMSNorm with these learned weights. Every architecture on the
140    /// generic path except the two families below.
141    Rms(Vec<f32>),
142    /// Non-parametric LayerNorm: subtract the mean, divide by the
143    /// standard deviation, no learned weight and no bias.
144    ///
145    /// `olmo` (OLMo-1) and nothing else in llama.cpp. The weighted form
146    /// is [`NormOp::LayerNorm`], which arrived only when `dbrx` gave it
147    /// a caller; the biased form still has none.
148    LayerNormNoParams,
149    /// `build_norm(x, nullptr, nullptr, LLM_NORM_RMS, il)`: `x /
150    /// sqrt(mean(x^2) + eps)` with no weight. `talkie`'s every norm
151    /// site (`capability::NON_PARAMETRIC_RMS_NORM`); the RMS twin of
152    /// [`Self::LayerNormNoParams`].
153    RmsNoParams,
154    /// LayerNorm with a learned weight and no bias:
155    /// `(x - mean) / sqrt(var + eps) * w`.
156    ///
157    /// `dbrx` (`dbrx.cpp:69-71`, `:110-112`, `:140-142`).
158    LayerNorm(Vec<f32>),
159    /// LayerNorm with a learned weight AND bias:
160    /// `(x - mean) / sqrt(var + eps) * w + b` -- `build_norm(x, w, b,
161    /// LLM_NORM, il)`, which multiplies `if (mw)` and then adds `if (mb)`
162    /// (`llama-graph.cpp`).
163    ///
164    /// `orion` (`orion.cpp:63-66,104-107,127-130`) and `nemotron`
165    /// (`nemotron.cpp:71-74,111-114,136-139`), both with all six per-layer
166    /// tensors and both output-norm tensors REQUIRED
167    /// (`capability::BIASED_LAYER_NORM`). The variant every row in the
168    /// old "LayerNorm-with-bias group" shares; it arrived when two rows
169    /// needed nothing else, and the six that need more say what.
170    LayerNormBias { weight: Vec<f32>, bias: Vec<f32> },
171    /// RMSNorm with a learned weight AND bias: `x / sqrt(mean(x^2) +
172    /// eps) * w + b` -- `build_norm(x, w, b, LLM_NORM_RMS, il)`, the
173    /// same multiply-then-add as [`Self::LayerNormBias`] over the RMS
174    /// rather than the centred vector.
175    ///
176    /// `phimoe` (Phi-3.5-MoE): `phimoe.cpp:20-21,28-29,35-36` create
177    /// the six per-layer and two output tensors REQUIRED, and its graph
178    /// is `phi3`'s (`models.h:632`), whose `phi3.cpp:99-102,137-139,
179    /// 174-177` pass each pair to `LLM_NORM_RMS`; `phi3` itself never
180    /// creates the biases, so its files take [`Self::Rms`]. One graph of
181    /// 140 on the generic path (`capability::BIASED_RMS_NORM`;
182    /// `deepseek32` and `glm-dsa` pass a bias to `LLM_NORM_RMS` on other
183    /// engines, `chameleon` passes NULL).
184    RmsBias { weight: Vec<f32>, bias: Vec<f32> },
185    /// No norm at all: the branch reads the raw residual.
186    ///
187    /// `olmo2` and `exaone4`. NOT "an RMSNorm whose weights are all
188    /// ones" -- that would still divide by the RMS of the residual, and
189    /// the whole point of this variant is that nothing is divided.
190    None,
191}
192
193impl NormOp {
194    /// The site's output: `rms_norm(x, w, eps)`, the non-parametric
195    /// LayerNorm, or `x` itself.
196    ///
197    /// Returns an owned vector in every arm, which is what every caller
198    /// already had: `rms_norm` allocates too.
199    ///
200    /// `eps` is `ModelConfig::rms_norm_eps`, which for a LayerNorm
201    /// architecture is read from `{arch}.attention.layer_norm_epsilon`
202    /// -- llama.cpp's `f_norm_eps` rather than `f_norm_rms_eps`. The two
203    /// are one field here because no architecture reads both keys, and
204    /// `loader.rs` already accepted either spelling into that field
205    /// before this variant existed.
206    pub fn apply(&self, x: &[f32], eps: f32) -> Vec<f32> {
207        match self {
208            Self::Rms(w) => rms_norm(x, w, eps),
209            Self::RmsNoParams => rms_norm_no_params(x, eps),
210            Self::LayerNormNoParams => layer_norm_no_params(x, eps),
211            Self::LayerNorm(w) => {
212                // `build_norm` (llama-graph.cpp): `ggml_norm`, then
213                // `ggml_mul(cur, mw)` -- the same centred vector as the
214                // parameterless variant, scaled per element.
215                let mut out = layer_norm_no_params(x, eps);
216                debug_assert_eq!(out.len(), w.len());
217                for (o, w) in out.iter_mut().zip(w.iter()) {
218                    *o *= w;
219                }
220                out
221            }
222            Self::LayerNormBias { weight, bias } => {
223                // The same, then `ggml_add(cur, mb)`: the bias lands
224                // AFTER the multiply, so it is not scaled by `w`.
225                let mut out = layer_norm_no_params(x, eps);
226                debug_assert_eq!(out.len(), weight.len());
227                debug_assert_eq!(out.len(), bias.len());
228                for ((o, w), b) in out.iter_mut().zip(weight.iter()).zip(bias.iter()) {
229                    *o = *o * w + b;
230                }
231                out
232            }
233            Self::RmsBias { weight, bias } => {
234                // `rms_norm` is the multiply; then the same `ggml_add`.
235                let mut out = rms_norm(x, weight, eps);
236                debug_assert_eq!(out.len(), bias.len());
237                for (o, b) in out.iter_mut().zip(bias.iter()) {
238                    *o += b;
239                }
240                out
241            }
242            Self::None => x.to_vec(),
243        }
244    }
245
246    /// The weights a fused GPU kernel needs, or `None` when this site
247    /// has no RMSNorm weights and the kernel therefore must not run.
248    ///
249    /// Every fused Metal launch that bakes an RMSNorm into its kernel
250    /// goes through here and falls back to the host body on `None`.
251    /// See the module docs for why that is a `?` and not a comment.
252    pub fn rms_weights(&self) -> Option<&[f32]> {
253        match self {
254            Self::Rms(w) => Some(w),
255            // A biased RMSNorm has RMS weights, and no fused kernel adds
256            // the bias after them: the host body, not the launch.
257            Self::RmsNoParams
258            | Self::LayerNormNoParams
259            | Self::LayerNorm(_)
260            | Self::LayerNormBias { .. }
261            | Self::RmsBias { .. }
262            | Self::None => None,
263        }
264    }
265}
266
267/// One learned tensor of a norm site: the `.weight` or the `.bias`
268/// suffix of its GGUF name.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum NormParam {
271    Weight,
272    Bias,
273}
274
275impl NormParam {
276    /// The GGUF suffix.
277    pub fn suffix(self) -> &'static str {
278        match self {
279            NormParam::Weight => "weight",
280            NormParam::Bias => "bias",
281        }
282    }
283}
284
285/// The norm FUNCTION an architecture applies at its parametric sites --
286/// the `LLM_NORM` / `LLM_NORM_RMS` argument of `build_norm`, plus
287/// whether there is a weight to hand it.
288///
289/// One answer per architecture, read at every site. The loader used to
290/// decide this with a chain of `if` branches restated at the
291/// pre-attention, pre-FFN and final sites: three places that had to
292/// agree about one thing. `crate::norm_sites` resolves it ONCE through
293/// [`norm_function`] and the three sites read the result.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum NormFunction {
296    /// `LLM_NORM_RMS` with a weight. The generic path's default.
297    Rms,
298    /// `LLM_NORM` with a weight and no bias: `dbrx`
299    /// (`capability::WEIGHTED_LAYER_NORM`).
300    LayerNorm,
301    /// `LLM_NORM` with a weight AND a bias: `orion`, `nemotron`
302    /// (`capability::BIASED_LAYER_NORM`), [`NormOp::LayerNormBias`].
303    LayerNormBias,
304    /// `LLM_NORM` with neither: `olmo`
305    /// (`capability::NON_PARAMETRIC_LAYER_NORM`).
306    LayerNormNoParams,
307    /// `LLM_NORM_RMS` with a null weight: `talkie`
308    /// (`capability::NON_PARAMETRIC_RMS_NORM`), [`NormOp::RmsNoParams`].
309    RmsNoParams,
310    /// `LLM_NORM_RMS` with a weight AND a bias: `phimoe`
311    /// (`capability::BIASED_RMS_NORM`), [`NormOp::RmsBias`].
312    RmsBias,
313}
314
315impl NormFunction {
316    /// The [`NormOp`] for one site, reading its weight through `load`
317    /// only when this function has a weight to read.
318    ///
319    /// The closure rather than a `Vec<f32>` argument is the point:
320    /// `LayerNormNoParams` never calls it, so a file that carries no
321    /// norm tensor (OLMo-1 ships none) is never asked for one, and a
322    /// site cannot be handed a weight its function would drop.
323    ///
324    /// `load` is asked for each PART the function has -- `Weight`, and
325    /// for the biased form `Bias` too -- so a function that has no bias
326    /// never asks the file for one and the biased form cannot be built
327    /// with the bias forgotten.
328    pub fn resolve<E>(
329        self,
330        mut load: impl FnMut(NormParam) -> Result<Vec<f32>, E>,
331    ) -> Result<NormOp, E> {
332        Ok(match self {
333            Self::Rms => NormOp::Rms(load(NormParam::Weight)?),
334            Self::LayerNorm => NormOp::LayerNorm(load(NormParam::Weight)?),
335            Self::LayerNormBias => NormOp::LayerNormBias {
336                weight: load(NormParam::Weight)?,
337                bias: load(NormParam::Bias)?,
338            },
339            Self::LayerNormNoParams => NormOp::LayerNormNoParams,
340            Self::RmsNoParams => NormOp::RmsNoParams,
341            Self::RmsBias => NormOp::RmsBias {
342                weight: load(NormParam::Weight)?,
343                bias: load(NormParam::Bias)?,
344            },
345        })
346    }
347}
348
349/// Which norm function `arch` applies, from the two capability lists
350/// that name the exceptions.
351///
352/// Both lists are consulted here and nowhere else, so an architecture
353/// on both would be answered ONE way rather than by whichever branch
354/// came first -- and `crate::loader`'s
355/// `the_norm_slot_and_function_lists_cannot_contradict` pins that the
356/// situation never arises.
357pub fn norm_function(arch: &str) -> NormFunction {
358    norm_function_for_file(arch, None)
359}
360
361/// Architectures whose POST-attention and POST-FFN norms run at an eps
362/// the graph writes as a LITERAL, not at the model's
363/// `attention.layer_norm_rms_epsilon`.
364///
365/// `muse-glimmer.cpp:63` is `const float post_norm_eps = 1e-8f;` with
366/// the comment "Different to f_norm_rms_eps for post-attn / post-FFN
367/// norms", and `:140-141,166-167` call `ggml_rms_norm` with it while
368/// `:90,153` norm with the model's. `grep -rn 'post_norm_eps'
369/// src/models/*.cpp` over the 155 graphs is that one file (measured
370/// 2026-09-19), so for everybody else the two epsilons are the same
371/// number and [`post_norm_eps`] answers the model's.
372///
373/// A table rather than a second epsilon field on every config: the
374/// fact belongs to the architecture, and a `ModelConfig` that carried
375/// two independent epsilons would let a loader set one and forget the
376/// other.
377pub const POST_NORM_EPS_LITERAL: &[(&str, f32)] = &[("muse-glimmer", 1e-8)];
378
379/// The epsilon this architecture's post-norm sites run at, given the
380/// model's own. See [`POST_NORM_EPS_LITERAL`].
381pub fn post_norm_eps(arch: &str, model_eps: f32) -> f32 {
382    POST_NORM_EPS_LITERAL
383        .iter()
384        .find(|(a, _)| *a == arch)
385        .map_or(model_eps, |(_, eps)| *eps)
386}
387
388/// The ONE graph of 155 whose norm function is decided by the FILE:
389/// `cohere2moe.cpp:4-11` read both epsilon keys as optional, zero the
390/// RMS one when it is absent, and `:166,314` pick `LLM_NORM` when
391/// `f_norm_rms_eps == 0.0f` and `LLM_NORM_RMS` otherwise. Every real
392/// export writes `attention.layer_norm_epsilon` alone
393/// (`conversion/base.py:1354-1355` from `layer_norm_eps`), so the
394/// architecture's default is the weighted LayerNorm
395/// (`capability::WEIGHTED_LAYER_NORM`) and a file carrying a nonzero
396/// `attention.layer_norm_rms_epsilon` switches to RMS; measured
397/// (`grep -n 'f_norm_rms_eps == 0' src/models/*.cpp`).
398pub const NORM_BY_RMS_EPS_KEY: &[(&str, &str)] =
399    &[("cohere2moe", "src/models/cohere2moe.cpp:4-11,166")];
400
401/// [`norm_function`] with what the file declares for
402/// `attention.layer_norm_rms_epsilon`, for [`NORM_BY_RMS_EPS_KEY`];
403/// every other architecture ignores the argument.
404pub fn norm_function_for_file(arch: &str, declared_rms_eps: Option<f32>) -> NormFunction {
405    if NORM_BY_RMS_EPS_KEY.iter().any(|(a, _)| *a == arch)
406        && declared_rms_eps.is_some_and(|eps| eps != 0.0)
407    {
408        return NormFunction::Rms;
409    }
410    if crate::capability::uses_non_parametric_layer_norm(arch) {
411        NormFunction::LayerNormNoParams
412    } else if crate::capability::uses_non_parametric_rms_norm(arch) {
413        NormFunction::RmsNoParams
414    } else if crate::capability::uses_weighted_layer_norm(arch) {
415        NormFunction::LayerNorm
416    } else if crate::capability::uses_biased_layer_norm(arch) {
417        NormFunction::LayerNormBias
418    } else if crate::capability::uses_biased_rms_norm(arch) {
419        NormFunction::RmsBias
420    } else {
421        NormFunction::Rms
422    }
423}
424
425/// `(x - mean) / sqrt(var + eps)`, with the BIASED variance.
426///
427/// `ggml_compute_forward_norm_f32` (`ggml/src/ggml-cpu/ops.cpp:3716-3745`)
428/// divides the sum of squared deviations by `ne00`, not by `ne00 - 1`.
429/// Bessel's correction on a 4096-wide hidden state is a factor of
430/// 1.00012, which is far too small to fail a smoke test and far too
431/// large to be right.
432/// `ggml_rms_norm` with no multiply after it: `x * rsqrt(mean(x^2) + eps)`.
433pub fn rms_norm_no_params(x: &[f32], eps: f32) -> Vec<f32> {
434    let n = x.len() as f32;
435    debug_assert!(n > 0.0, "a norm site with no elements");
436    let mean_sq = x.iter().map(|v| v * v).sum::<f32>() / n;
437    let scale = 1.0 / (mean_sq + eps).sqrt();
438    x.iter().map(|v| v * scale).collect()
439}
440
441fn layer_norm_no_params(x: &[f32], eps: f32) -> Vec<f32> {
442    let n = x.len() as f32;
443    debug_assert!(n > 0.0, "a norm site with no elements");
444    let mean = x.iter().sum::<f32>() / n;
445    // The deviations are computed once and reused, which is also what
446    // ggml does: it writes `x - mean` into the destination and takes
447    // the variance from there.
448    let mut out: Vec<f32> = x.iter().map(|v| v - mean).collect();
449    let var = out.iter().map(|d| d * d).sum::<f32>() / n;
450    let scale = 1.0 / (var + eps).sqrt();
451    for v in out.iter_mut() {
452        *v *= scale;
453    }
454    out
455}
456
457// There is deliberately no `is_present()` beside `rms_weights()`.
458// "Does this site norm?" and "what weights does the kernel get?" are
459// the same fact for a fused kernel, and two spellings of one fact is
460// the shape this repo keeps shipping bugs in: `rms_weights().is_some()`
461// is the only way to ask.
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    /// `NormOp::None` is the identity, and an all-ones RMSNorm is not.
468    ///
469    /// The tempting shortcut for `olmo2` / `exaone4` was to load a
470    /// vector of ones into the existing slot and change nothing else.
471    /// It is wrong by exactly the RMS scale factor, which for a residual
472    /// with any magnitude at all is not close to 1. This test is what
473    /// stops somebody re-discovering that as a "simplification".
474    #[test]
475    fn no_norm_is_the_identity_and_an_all_ones_rmsnorm_is_not() {
476        let x = vec![3.0f32, -4.0, 12.0, 0.5];
477        let eps = 1e-5;
478
479        assert_eq!(NormOp::None.apply(&x, eps), x);
480
481        let ones = NormOp::Rms(vec![1.0; x.len()]);
482        let normed = ones.apply(&x, eps);
483        let worst = x
484            .iter()
485            .zip(normed.iter())
486            .map(|(a, b)| (a - b).abs())
487            .fold(0f32, f32::max);
488        assert!(
489            worst > 1.0,
490            "an all-ones RMSNorm moved the vector by only {worst}; if it were the \
491             identity the post-norm-only topology would not need a variant at all"
492        );
493    }
494
495    /// The non-parametric LayerNorm is not the all-ones RMSNorm either,
496    /// and the difference is the MEAN.
497    ///
498    /// This is the shortcut somebody will reach for next: "OLMo-1 just
499    /// has no norm weights, so load ones". On a vector with a non-zero
500    /// mean the two differ in every element, and the assertion below
501    /// measures that rather than trusting it -- a centred input would
502    /// make them agree and would prove nothing.
503    #[test]
504    fn the_layer_norm_subtracts_the_mean_and_an_all_ones_rmsnorm_does_not() {
505        let x = vec![3.0f32, -4.0, 12.0, 0.5];
506        let eps = 1e-5;
507        let ln = NormOp::LayerNormNoParams.apply(&x, eps);
508        let rms = NormOp::Rms(vec![1.0; x.len()]).apply(&x, eps);
509
510        let mean: f32 = x.iter().sum::<f32>() / x.len() as f32;
511        assert!(mean.abs() > 1.0, "the input must not be centred: {mean}");
512
513        let out_mean: f32 = ln.iter().sum::<f32>() / ln.len() as f32;
514        assert!(
515            out_mean.abs() < 1e-5,
516            "a LayerNorm's output is centred; got mean {out_mean}"
517        );
518
519        let worst = ln
520            .iter()
521            .zip(rms.iter())
522            .map(|(a, b)| (a - b).abs())
523            .fold(0f32, f32::max);
524        assert!(
525            worst > 0.1,
526            "the two norms differ by only {worst}; an all-ones RMSNorm would then be a \
527             legitimate stand-in for OLMo-1's norm and this variant would be decoration"
528        );
529    }
530
531    /// The variance is BIASED (divide by n), which is ggml's.
532    ///
533    /// Checked against arithmetic written out here rather than against
534    /// the implementation: the two spellings differ by sqrt(n/(n-1)),
535    /// which on this 4-element vector is 1.155 and on a real hidden
536    /// state is 1.0001 -- big enough to be wrong, small enough that no
537    /// end-to-end smoke test would notice.
538    #[test]
539    fn the_variance_is_the_biased_one_ggml_uses() {
540        let x = [1.0f32, 2.0, 3.0, 10.0];
541        let got = NormOp::LayerNormNoParams.apply(&x, 0.0);
542
543        let n = x.len() as f64;
544        let mean = x.iter().map(|v| *v as f64).sum::<f64>() / n;
545        let var = x.iter().map(|v| (*v as f64 - mean).powi(2)).sum::<f64>() / n;
546        let want: Vec<f32> = x
547            .iter()
548            .map(|v| ((*v as f64 - mean) / var.sqrt()) as f32)
549            .collect();
550
551        for (g, w) in got.iter().zip(want.iter()) {
552            assert!((g - w).abs() < 1e-5, "got {got:?}, want {want:?}");
553        }
554
555        // ... and the SAMPLE variance would be visibly different here,
556        // so this test can tell them apart.
557        let sample = x.iter().map(|v| (*v as f64 - mean).powi(2)).sum::<f64>() / (n - 1.0);
558        let worst = got
559            .iter()
560            .zip(x.iter())
561            .map(|(g, v)| (g - ((*v as f64 - mean) / sample.sqrt()) as f32).abs())
562            .fold(0f32, f32::max);
563        assert!(worst > 0.1, "the two variances differ by only {worst}");
564    }
565
566    /// A fused GPU launch cannot be handed weights that do not exist,
567    /// from EITHER non-RMS variant.
568    #[test]
569    fn only_the_rms_variant_offers_weights_to_a_fused_kernel() {
570        assert_eq!(
571            NormOp::Rms(vec![2.0, 3.0]).rms_weights(),
572            Some(&[2.0f32, 3.0][..])
573        );
574        assert_eq!(NormOp::None.rms_weights(), None);
575        assert_eq!(NormOp::LayerNormNoParams.rms_weights(), None);
576        assert_eq!(NormOp::LayerNorm(vec![2.0, 3.0]).rms_weights(), None);
577    }
578
579    /// The weighted LayerNorm is the parameterless one times its
580    /// weight, element by element -- `build_norm`'s `ggml_norm` then
581    /// `ggml_mul(cur, mw)`.
582    ///
583    /// Checked against the composition rather than against a rewrite of
584    /// the arithmetic, because the composition IS the claim: if the
585    /// variant ever centred differently from `LayerNormNoParams`, `olmo`
586    /// and `dbrx` would disagree about what `LLM_NORM` means.
587    #[test]
588    fn the_weighted_layer_norm_is_the_parameterless_one_times_its_weight() {
589        let x = vec![3.0f32, -4.0, 12.0, 0.5];
590        let w = vec![0.5f32, -2.0, 1.5, 4.0];
591        let eps = 1e-5;
592        let got = NormOp::LayerNorm(w.clone()).apply(&x, eps);
593        let base = NormOp::LayerNormNoParams.apply(&x, eps);
594        for ((g, b), w) in got.iter().zip(base.iter()).zip(w.iter()) {
595            assert!((g - b * w).abs() < 1e-6, "got {got:?}, base {base:?}");
596        }
597    }
598
599    /// ... and it is NOT an RMSNorm with the same weight, which is the
600    /// substitution a loader makes by reading `dbrx`'s `attn_norm.weight`
601    /// into the slot every other architecture uses.
602    #[test]
603    fn the_weighted_layer_norm_is_not_an_rmsnorm_with_the_same_weight() {
604        let x = vec![3.0f32, -4.0, 12.0, 0.5];
605        let w = vec![0.5f32, -2.0, 1.5, 4.0];
606        let eps = 1e-5;
607        let ln = NormOp::LayerNorm(w.clone()).apply(&x, eps);
608        let rms = NormOp::Rms(w).apply(&x, eps);
609        let worst = ln
610            .iter()
611            .zip(rms.iter())
612            .map(|(a, b)| (a - b).abs())
613            .fold(0f32, f32::max);
614        assert!(worst > 0.1, "the two norms differ by only {worst}");
615    }
616
617    /// `NormFunction` maps each capability list to exactly one variant,
618    /// and the default is RMS.
619    #[test]
620    fn the_norm_function_is_read_off_the_capability_lists() {
621        assert_eq!(norm_function("olmo"), NormFunction::LayerNormNoParams);
622        assert_eq!(norm_function("dbrx"), NormFunction::LayerNorm);
623        for arch in ["llama", "qwen3", "olmo2", "gemma3", "grok"] {
624            assert_eq!(norm_function(arch), NormFunction::Rms, "{arch}");
625        }
626        assert_eq!(norm_function("orion"), NormFunction::LayerNormBias);
627        assert_eq!(norm_function("nemotron"), NormFunction::LayerNormBias);
628        let w = |p: NormParam| -> Result<Vec<f32>, ()> {
629            Ok(match p {
630                NormParam::Weight => vec![1.0, 2.0],
631                NormParam::Bias => vec![0.5, -0.5],
632            })
633        };
634        assert_eq!(
635            NormFunction::LayerNorm.resolve(w),
636            Ok(NormOp::LayerNorm(vec![1.0, 2.0]))
637        );
638        assert_eq!(
639            NormFunction::Rms.resolve(w),
640            Ok(NormOp::Rms(vec![1.0, 2.0]))
641        );
642        assert_eq!(
643            NormFunction::LayerNormBias.resolve(w),
644            Ok(NormOp::LayerNormBias {
645                weight: vec![1.0, 2.0],
646                bias: vec![0.5, -0.5],
647            })
648        );
649    }
650
651    /// The parameterless function never asks the file for a weight.
652    ///
653    /// OLMo-1 files carry no norm tensor at all, so a loader that read
654    /// one "just in case" would fail on every real checkpoint; and a
655    /// loader that read one and dropped it would hide a file that is
656    /// not what the architecture string says.
657    #[test]
658    fn the_parameterless_function_never_reads_a_weight() {
659        let mut asked = false;
660        let got = NormFunction::LayerNormNoParams.resolve(|_| -> Result<Vec<f32>, ()> {
661            asked = true;
662            Err(())
663        });
664        assert_eq!(got, Ok(NormOp::LayerNormNoParams));
665        assert!(!asked, "the loader closure must not run");
666    }
667}