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}