Skip to main content

kopitiam_tensor/tensor/
norm.rs

1//! `rms_norm` and `layer_norm`, applied along the last dimension.
2//!
3//! These live in `kopitiam-tensor` rather than a later transformer-graph
4//! crate because every transformer architecture this runtime will ever
5//! load uses one of them at every layer boundary — duplicating the
6//! reduction-and-scale logic in each model's graph-building code would be
7//! exactly the "duplicated logic" `CLAUDE.md` asks to avoid.
8
9use kopitiam_core::{DType, Error, Result};
10
11use crate::storage::Storage;
12
13use super::Tensor;
14
15impl Tensor {
16    /// RMSNorm (Zhang & Sennrich, 2019): `x / rms(x) * weight`, where
17    /// `rms(x) = sqrt(mean(x^2) + eps)`, computed independently for every
18    /// vector along the last dimension.
19    ///
20    /// `weight` must have exactly `hidden` elements, where `hidden` is
21    /// `self`'s last dimension.
22    pub fn rms_norm(&self, weight: &Tensor, eps: f32) -> Result<Tensor> {
23        self.require_dtype(DType::F32)?;
24        let hidden = self.last_dim()?;
25        let w = self.matching_weight(weight, hidden)?;
26
27        let Storage::F32(data) = self.storage.as_ref() else { unreachable!() };
28        let contiguous: Vec<f32> = self.logical_offsets().map(|i| data[i]).collect();
29
30        let mut out = vec![0f32; contiguous.len()];
31        for (row_out, row_in) in out.chunks_mut(hidden).zip(contiguous.chunks(hidden)) {
32            let mean_square = row_in.iter().map(|v| v * v).sum::<f32>() / hidden as f32;
33            let inv_rms = 1.0 / (mean_square + eps).sqrt();
34            for (o, (&x, &w)) in row_out.iter_mut().zip(row_in.iter().zip(&w)) {
35                *o = x * inv_rms * w;
36            }
37        }
38        Tensor::from_f32(out, self.shape.clone())
39    }
40
41    /// Layer normalization (Ba, Kiros & Hinton, 2016):
42    /// `(x - mean(x)) / sqrt(var(x) + eps) * weight + bias`, with `mean`
43    /// and the (population, i.e. divide-by-N) `var` computed independently
44    /// for every vector along the last dimension.
45    pub fn layer_norm(&self, weight: &Tensor, bias: &Tensor, eps: f32) -> Result<Tensor> {
46        self.require_dtype(DType::F32)?;
47        let hidden = self.last_dim()?;
48        let w = self.matching_weight(weight, hidden)?;
49        let b = self.matching_weight(bias, hidden)?;
50
51        let Storage::F32(data) = self.storage.as_ref() else { unreachable!() };
52        let contiguous: Vec<f32> = self.logical_offsets().map(|i| data[i]).collect();
53
54        let mut out = vec![0f32; contiguous.len()];
55        for (row_out, row_in) in out.chunks_mut(hidden).zip(contiguous.chunks(hidden)) {
56            let mean = row_in.iter().sum::<f32>() / hidden as f32;
57            let variance = row_in.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / hidden as f32;
58            let inv_std = 1.0 / (variance + eps).sqrt();
59            for (i, o) in row_out.iter_mut().enumerate() {
60                *o = (row_in[i] - mean) * inv_std * w[i] + b[i];
61            }
62        }
63        Tensor::from_f32(out, self.shape.clone())
64    }
65
66    fn last_dim(&self) -> Result<usize> {
67        if self.rank() == 0 {
68            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: self.shape.clone() });
69        }
70        Ok(self.shape.dims()[self.rank() - 1])
71    }
72
73    /// Validates that `weight` is a plain `f32` vector of exactly `hidden`
74    /// elements and materializes it, so the per-row loop can index it
75    /// directly instead of re-deriving strided offsets per element.
76    fn matching_weight(&self, weight: &Tensor, hidden: usize) -> Result<Vec<f32>> {
77        weight.require_dtype(DType::F32)?;
78        if weight.elem_count() != hidden {
79            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: weight.shape.clone() });
80        }
81        weight.to_vec_f32()
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn rms_norm_matches_hand_computation() {
91        // x = [3, 4], mean(x^2) = (9+16)/2 = 12.5, rms = sqrt(12.5).
92        // With weight = [1, 1] and eps = 0: out = x / sqrt(12.5).
93        let x = Tensor::from_f32(vec![3.0, 4.0], [1, 2]).unwrap();
94        let w = Tensor::from_f32(vec![1.0, 1.0], [2]).unwrap();
95        let out = x.rms_norm(&w, 0.0).unwrap().to_vec_f32().unwrap();
96        let expected_scale = 1.0 / 12.5f32.sqrt();
97        assert!((out[0] - 3.0 * expected_scale).abs() < 1e-6);
98        assert!((out[1] - 4.0 * expected_scale).abs() < 1e-6);
99    }
100
101    #[test]
102    fn rms_norm_applies_the_per_channel_weight() {
103        let x = Tensor::from_f32(vec![1.0, 1.0], [1, 2]).unwrap();
104        let w = Tensor::from_f32(vec![2.0, 3.0], [2]).unwrap();
105        // rms([1,1]) = 1, so out = x * w directly.
106        let out = x.rms_norm(&w, 0.0).unwrap().to_vec_f32().unwrap();
107        assert!((out[0] - 2.0).abs() < 1e-6);
108        assert!((out[1] - 3.0).abs() < 1e-6);
109    }
110
111    #[test]
112    fn layer_norm_matches_hand_computation() {
113        // x = [1, 2, 3, 4], mean=2.5, var = ((1.5)^2+(0.5)^2+(0.5)^2+(1.5)^2)/4 = 1.25
114        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 4]).unwrap();
115        let w = Tensor::from_f32(vec![1.0; 4], [4]).unwrap();
116        let b = Tensor::from_f32(vec![0.0; 4], [4]).unwrap();
117        let out = x.layer_norm(&w, &b, 0.0).unwrap().to_vec_f32().unwrap();
118        let inv_std = 1.0 / 1.25f32.sqrt();
119        let expected = [
120            (1.0 - 2.5) * inv_std,
121            (2.0 - 2.5) * inv_std,
122            (3.0 - 2.5) * inv_std,
123            (4.0 - 2.5) * inv_std,
124        ];
125        for (o, e) in out.iter().zip(expected) {
126            assert!((o - e).abs() < 1e-5, "got {o}, expected {e}");
127        }
128        // Normalized output has zero mean.
129        assert!(out.iter().sum::<f32>().abs() < 1e-5);
130    }
131
132    #[test]
133    fn layer_norm_applies_weight_and_bias_after_normalizing() {
134        let x = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0], [1, 4]).unwrap();
135        let unit_w = Tensor::from_f32(vec![1.0; 4], [4]).unwrap();
136        let zero_b = Tensor::from_f32(vec![0.0; 4], [4]).unwrap();
137        let normalized = x.layer_norm(&unit_w, &zero_b, 0.0).unwrap().to_vec_f32().unwrap();
138
139        let w = Tensor::from_f32(vec![2.0; 4], [4]).unwrap();
140        let b = Tensor::from_f32(vec![10.0; 4], [4]).unwrap();
141        let out = x.layer_norm(&w, &b, 0.0).unwrap().to_vec_f32().unwrap();
142
143        for (o, n) in out.iter().zip(&normalized) {
144            assert!((o - (n * 2.0 + 10.0)).abs() < 1e-5, "got {o}, expected {}", n * 2.0 + 10.0);
145        }
146    }
147
148    #[test]
149    fn norm_ops_reject_a_mismatched_weight_length() {
150        let x = Tensor::from_f32(vec![1.0, 2.0], [1, 2]).unwrap();
151        let w = Tensor::from_f32(vec![1.0, 1.0, 1.0], [3]).unwrap();
152        assert!(matches!(x.rms_norm(&w, 1e-5), Err(Error::ShapeMismatch { .. })));
153    }
154}