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}