Skip to main content

frink_models/
lightning.rs

1//! MiniMax-01's lightning attention block.
2//!
3//! The math is `frink_core::lightning`, which had been written and left
4//! with no caller; this is the layer around it, the way
5//! `crate::gdn` is the layer around `frink_core::gdn`.
6//!
7//! # The shape, from `src/models/minimax-01.cpp`
8//!
9//! A recurrent layer (`:44-51`) carries a FUSED `attn_qkv`
10//! `{n_embd, 3 * n_head * head_dim}`, an `attn_gate` `wg`
11//! `{n_embd, n_head * head_dim}`, an `attn_norm_2`
12//! `{n_head * head_dim}` and the usual `attn_output`. A full-attention
13//! layer in the same file carries an ordinary QKV instead, which is
14//! why the mask that says which is which
15//! (`crate::gdn::recurrent_layers`) is read before any tensor is.
16//!
17//! `:398-416` is the tail and it is NOT the attention tail this repo
18//! already has: the block output is RMS-normed by `attn_norm_2`,
19//! multiplied by `sigmoid(wg . x)` and only then sent through `wo`.
20//! That is the delta-net's gated-norm shape rather than Falcon's
21//! crossed pre-norm, which is what the refusal's mention of
22//! `attn_norm_2` reads like until the graph is opened.
23//!
24//! # Two facts a reader of the tensor shapes alone gets wrong
25//!
26//! Both were wrong here for one PR, and neither shows up as a panic --
27//! the shapes agree either way and the numbers stay plausible, which
28//! is why they are pinned by the libllama golden rather than by
29//! inspection.
30//!
31//! **The projection runs through SiLU before it is split.**
32//! `:303` is `QKVcur = ggml_silu(ctx0, QKVcur)` on the whole
33//! `3 * n_head * head_dim` output, so Q, K and V are each
34//! `silu(W x)` and not `W x`. No other fused QKV in this repo does
35//! that.
36//!
37//! **The fused projection is HEAD-major, not Q-then-K-then-V.**
38//! `:305` reshapes it to `{3 * head_dim, n_head, ...}` and `:307-309`
39//! view Q, K and V at element offsets `0`, `head_dim` and
40//! `2 * head_dim` INSIDE that first axis, so head `h` owns the
41//! contiguous run `[q_h | k_h | v_h]` at `h * 3 * head_dim`. Reading
42//! it as three `n_head * head_dim` blocks is a permutation of the
43//! rows, which for a one-head fixture is the identity -- so the
44//! fixture has two.
45//!
46//! # Per token, not per chunk
47//!
48//! `:315-388` computes a whole ubatch at once with three decay inputs
49//! the host fills. `frink_core::lightning` proves that form equal to
50//! the per-token recurrence for the case that matters here, and the
51//! recurrence is what lets a decode step and a prefill row share one
52//! body -- the same reason every other recurrent block in this repo is
53//! written that way.
54
55use frink_core::lightning::{lightning_step, slope_scale, slopes};
56use frink_core::matmul::rms_norm;
57use frink_core::recurrent_state::RecurrentState;
58use frink_core::weight_matrix::WeightMatrix;
59use frink_gguf::TensorSource;
60
61use crate::loader::{load_f32_vec, load_weight_matrix, LoadError};
62
63/// One lightning layer's weights, and the two counts that size them.
64pub struct Lightning {
65    /// Query heads. Uniform across the file: `minimax-01.cpp:44-51`
66    /// sizes every recurrent layer from `n_head` and `n_embd_head_k`.
67    pub n_head: usize,
68    /// `n_embd_head_k`, which `llama-hparams.cpp:249-253` also uses as
69    /// `n_embd_head_la` to size the recurrent state.
70    pub head_dim: usize,
71    /// This layer's decay, a function of the layer index and the head
72    /// count alone.
73    pub decay: LightningDecay,
74    /// `blk.N.attn_qkv.weight`, `[3 * n_head * head_dim, n_embd]`,
75    /// HEAD-major (see the module docs).
76    pub qkv: WeightMatrix,
77    /// `blk.N.attn_gate.weight`, `[n_head * head_dim, n_embd]`.
78    pub gate: WeightMatrix,
79    /// `blk.N.attn_norm_2.weight`, `[n_head * head_dim]`.
80    pub norm: Vec<f32>,
81    /// `blk.N.attn_output.weight`, `[n_embd, n_head * head_dim]`. The
82    /// same tensor NAME a full-attention layer of the same file uses
83    /// (`:53`), which is why it is loaded here rather than left on
84    /// `AttnWeights::o_proj`: the tail applies it after the gate, not
85    /// where the attention tail would.
86    pub out_proj: WeightMatrix,
87}
88
89/// The two numbers a layer's decay is built from.
90///
91/// Split out because both are functions of the LAYER and the head
92/// count rather than of the file: `minimax-01.cpp:288` derives the
93/// per-layer scale from the layer index, and the slopes are the
94/// geometric ladder ALiBi uses. A file that carried them would be
95/// carrying dead metadata.
96#[derive(Debug, Clone)]
97pub struct LightningDecay {
98    /// One slope per head, in head order.
99    pub slopes: Vec<f32>,
100    /// This layer's scale on every slope.
101    pub scale: f32,
102}
103
104impl LightningDecay {
105    pub fn for_layer(il: usize, n_layer: usize, n_head: usize) -> Self {
106        LightningDecay {
107            slopes: slopes(n_head),
108            scale: slope_scale(il, n_layer),
109        }
110    }
111
112    /// `exp(-c s_h)`: what one token multiplies head `h`'s state by.
113    pub fn per_step(&self, head: usize) -> f32 {
114        (-self.scale * self.slopes[head]).exp()
115    }
116}
117
118impl Lightning {
119    /// Rows the per-head state needs: `head_dim * head_dim` per head.
120    ///
121    /// `llama-hparams.cpp:253` spells the same product
122    /// `n_embd_head_la * n_embd_head_la * n_head()`.
123    pub fn state_len(n_head: usize, head_dim: usize) -> usize {
124        n_head * head_dim * head_dim
125    }
126
127    /// Loads layer `layer`'s four tensors and checks them against
128    /// `minimax-01.cpp:44-53`'s shapes.
129    ///
130    /// `n_layer` is the LOGICAL layer count the graph loops over,
131    /// because the decay scale is `1 - il / (n_layer - 1)` and nothing
132    /// in the file carries it.
133    pub fn load(
134        file: &impl TensorSource,
135        layer: usize,
136        n_layer: usize,
137        n_head: usize,
138        head_dim: usize,
139        hidden_dim: usize,
140    ) -> Result<Self, LoadError> {
141        let width = n_head * head_dim;
142        let name = |t: &str| format!("blk.{layer}.{t}");
143        let matrix = |t: &str, rows: usize, cols: usize| -> Result<WeightMatrix, LoadError> {
144            let m = load_weight_matrix(file, &name(t))?;
145            if m.rows() != rows || m.cols() != cols {
146                return Err(LoadError::UnsupportedFeature(
147                    name(t),
148                    format!(
149                        "{}x{}; minimax-01.cpp:44-53 sizes it {rows}x{cols}",
150                        m.rows(),
151                        m.cols()
152                    ),
153                ));
154            }
155            Ok(m)
156        };
157        let norm = load_f32_vec(file, &name("attn_norm_2.weight"))?;
158        if norm.len() != width {
159            return Err(LoadError::UnsupportedFeature(
160                name("attn_norm_2.weight"),
161                format!(
162                    "{} entries; minimax-01.cpp:48 sizes it n_head * head_dim = {width}",
163                    norm.len()
164                ),
165            ));
166        }
167        Ok(Lightning {
168            n_head,
169            head_dim,
170            decay: LightningDecay::for_layer(layer, n_layer, n_head),
171            qkv: matrix("attn_qkv.weight", 3 * width, hidden_dim)?,
172            gate: matrix("attn_gate.weight", width, hidden_dim)?,
173            norm,
174            out_proj: matrix("attn_output.weight", hidden_dim, width)?,
175        })
176    }
177
178    /// A fresh sequence's state: one `head_dim x head_dim` KV per head
179    /// and no convolution window.
180    pub fn zero_state(&self) -> RecurrentState {
181        RecurrentState::zeros(0, Self::state_len(self.n_head, self.head_dim))
182    }
183
184    /// `rows` consecutive tokens of ONE sequence (`normed` is
185    /// `[rows][n_embd]`) through the block, advancing `state` in place.
186    pub fn forward_rows(
187        &self,
188        normed: &[f32],
189        rows: usize,
190        state: &mut RecurrentState,
191        rms_eps: f32,
192    ) -> Vec<f32> {
193        let hidden = self.out_proj.rows();
194        assert_eq!(normed.len(), rows * hidden);
195        assert_eq!(
196            state.ssm.len(),
197            Self::state_len(self.n_head, self.head_dim),
198            "lightning state sized by these weights"
199        );
200        let mut out = Vec::with_capacity(rows * hidden);
201        for row in normed.chunks(hidden) {
202            out.extend(self.forward_row(row, &mut state.ssm, rms_eps));
203        }
204        out
205    }
206
207    /// One token through the block, advancing `state` in place.
208    ///
209    /// NOT called `forward_token`: that name is reserved for the
210    /// entry point that enters the CPU worker pool, and
211    /// `engine::entry` fails the build for any other declaration of
212    /// it. `forward_row` is what the other recurrent blocks call the
213    /// same thing.
214    ///
215    /// `x` is the layer input AFTER `attn_norm`, as the graph has it
216    /// (`:308`), and the return is what joins the residual -- `wo`
217    /// applied, the gate and the norm already inside.
218    pub fn forward_row(&self, x: &[f32], state: &mut [f32], eps: f32) -> Vec<f32> {
219        let (n_head, head_dim) = (self.n_head, self.head_dim);
220        let width = n_head * head_dim;
221        debug_assert_eq!(state.len(), Self::state_len(n_head, head_dim));
222
223        // `:303`: the WHOLE projection through SiLU, before the split.
224        let mut qkv = self.qkv.apply(x);
225        debug_assert_eq!(qkv.len(), 3 * width);
226        for v in &mut qkv {
227            *v = *v / (1.0 + (-*v).exp());
228        }
229
230        // Per head, and the heads do not talk to each other: the state
231        // is `head_dim x head_dim` per head and the recurrence is the
232        // one `frink_core::lightning` pins against the chunked form.
233        // Head `h` owns `[q | k | v]` at `h * 3 * head_dim` (`:305-309`).
234        let mut inner = vec![0.0f32; width];
235        for h in 0..n_head {
236            let base = h * 3 * head_dim;
237            let (q, k, v) = (
238                &qkv[base..base + head_dim],
239                &qkv[base + head_dim..base + 2 * head_dim],
240                &qkv[base + 2 * head_dim..base + 3 * head_dim],
241            );
242            let s_lo = h * head_dim * head_dim;
243            let s_hi = s_lo + head_dim * head_dim;
244            let out = lightning_step(q, k, v, &mut state[s_lo..s_hi], self.decay.per_step(h));
245            inner[h * head_dim..(h + 1) * head_dim].copy_from_slice(&out);
246        }
247
248        // The tail: norm, then the sigmoid gate, then `wo`. The gate
249        // reads the LAYER INPUT, not the block output (`:406`), which
250        // is the one thing a reader is likely to get backwards.
251        let normed = rms_norm(&inner, &self.norm, eps);
252        let gate = self.gate.apply(x);
253        let gated: Vec<f32> = normed
254            .iter()
255            .zip(&gate)
256            .map(|(n, g)| n * (1.0 / (1.0 + (-g).exp())))
257            .collect();
258        self.out_proj.apply(&gated)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use frink_core::Tensor;
266
267    fn matrix(rows: usize, cols: usize, seed: f32) -> WeightMatrix {
268        let v: Vec<f32> = (0..rows * cols)
269            .map(|i| ((i as f32 + seed) * 0.017).sin() * 0.5)
270            .collect();
271        WeightMatrix::F32(Tensor::new(v, vec![rows, cols]))
272    }
273
274    fn sigmoid(x: f32) -> f32 {
275        1.0 / (1.0 + (-x).exp())
276    }
277
278    fn silu(x: f32) -> f32 {
279        x / (1.0 + (-x).exp())
280    }
281
282    fn block(
283        n_head: usize,
284        head_dim: usize,
285        n_embd: usize,
286        il: usize,
287        n_layer: usize,
288    ) -> Lightning {
289        let width = n_head * head_dim;
290        Lightning {
291            n_head,
292            head_dim,
293            decay: LightningDecay::for_layer(il, n_layer, n_head),
294            qkv: matrix(3 * width, n_embd, 1.0),
295            gate: matrix(width, n_embd, 2.0),
296            norm: (0..width).map(|i| 1.0 + i as f32 * 0.01).collect(),
297            out_proj: matrix(n_embd, width, 3.0),
298        }
299    }
300
301    /// The chunked form `minimax-01.cpp:315-388` computes, transcribed
302    /// straight from the graph rather than from this module, so the
303    /// two are independent statements of the same function:
304    ///
305    /// ```text
306    ///   out[j] = (q[j] * exp(-c s (j+1))) @ KV_0
307    ///          + sum_{i<=j} (q[j].k[i]) exp(-c s (j - i)) v[i]
308    /// ```
309    ///
310    /// `KV_0` is zero here because a fresh sequence starts with no
311    /// state, which is the case a prefill actually runs.
312    fn chunked_reference(
313        q: &[Vec<f32>],
314        k: &[Vec<f32>],
315        v: &[Vec<f32>],
316        c_s: f32,
317    ) -> Vec<Vec<f32>> {
318        let n = q.len();
319        let d = q[0].len();
320        let mut out = vec![vec![0.0f32; d]; n];
321        for j in 0..n {
322            for i in 0..=j {
323                let qk: f32 = q[j].iter().zip(&k[i]).map(|(a, b)| a * b).sum();
324                let decay = (-c_s * (j - i) as f32).exp();
325                for t in 0..d {
326                    out[j][t] += qk * decay * v[i][t];
327                }
328            }
329        }
330        out
331    }
332
333    /// The per-token recurrence this block runs has to agree with the
334    /// chunked form the graph computes, over a whole prefill and not
335    /// just one step. `frink_core::lightning` pins the two-token case;
336    /// this is the case a real prompt takes, through the projections.
337    ///
338    /// TWO heads, because the head-major split of the fused projection
339    /// (`:305-309`) is the identity at one head and a permutation at
340    /// two.
341    #[test]
342    fn the_block_agrees_with_the_graphs_chunked_form() {
343        let (n_head, head_dim, n_embd, n_tokens) = (2usize, 4usize, 6usize, 5usize);
344        let width = n_head * head_dim;
345        let layer = block(n_head, head_dim, n_embd, 1, 4);
346        let xs: Vec<Vec<f32>> = (0..n_tokens)
347            .map(|t| {
348                (0..n_embd)
349                    .map(|i| ((t * n_embd + i) as f32 * 0.03).cos())
350                    .collect()
351            })
352            .collect();
353
354        // What the block produces, token by token.
355        let mut state = vec![0.0f32; Lightning::state_len(n_head, head_dim)];
356        let got: Vec<Vec<f32>> = xs
357            .iter()
358            .map(|x| layer.forward_row(x, &mut state, 1e-5))
359            .collect();
360
361        // What the graph's chunked form produces, per head, from the
362        // same projections -- SiLU applied and split head-major, as
363        // `:303-309` do.
364        let projected: Vec<Vec<f32>> = xs
365            .iter()
366            .map(|x| layer.qkv.apply(x).iter().map(|v| silu(*v)).collect())
367            .collect();
368        let mut inner = vec![vec![0.0f32; width]; n_tokens];
369        for h in 0..n_head {
370            let base = h * 3 * head_dim;
371            let take = |o: usize| -> Vec<Vec<f32>> {
372                projected
373                    .iter()
374                    .map(|p| p[base + o * head_dim..base + (o + 1) * head_dim].to_vec())
375                    .collect()
376            };
377            let rows = chunked_reference(
378                &take(0),
379                &take(1),
380                &take(2),
381                layer.decay.scale * layer.decay.slopes[h],
382            );
383            for (t, row) in rows.iter().enumerate() {
384                inner[t][h * head_dim..(h + 1) * head_dim].copy_from_slice(row);
385            }
386        }
387        let want: Vec<Vec<f32>> = xs
388            .iter()
389            .zip(&inner)
390            .map(|(x, i)| {
391                let normed = rms_norm(i, &layer.norm, 1e-5);
392                let g = layer.gate.apply(x);
393                let gated: Vec<f32> = normed
394                    .iter()
395                    .zip(&g)
396                    .map(|(n, gg)| n * sigmoid(*gg))
397                    .collect();
398                layer.out_proj.apply(&gated)
399            })
400            .collect();
401
402        for (t, (a, b)) in got.iter().zip(&want).enumerate() {
403            for (i, (x, y)) in a.iter().zip(b).enumerate() {
404                assert!(
405                    (x - y).abs() < 1e-5,
406                    "token {t} element {i}: recurrence {x} vs chunked {y}"
407                );
408            }
409        }
410    }
411
412    /// `forward_rows` over a whole prompt is `forward_row` in a loop:
413    /// the batched host body and the decode step share ONE recurrence,
414    /// so a prefill and the decode that follows it cannot disagree
415    /// about the state.
416    #[test]
417    fn many_rows_are_the_same_as_one_row_at_a_time() {
418        let (n_head, head_dim, n_embd, n_tokens) = (2usize, 3usize, 5usize, 4usize);
419        let layer = block(n_head, head_dim, n_embd, 0, 3);
420        let xs: Vec<f32> = (0..n_tokens * n_embd)
421            .map(|i| (i as f32 * 0.11).sin())
422            .collect();
423
424        let mut batched = layer.zero_state();
425        let got = layer.forward_rows(&xs, n_tokens, &mut batched, 1e-5);
426
427        let mut one = layer.zero_state();
428        let mut want = Vec::new();
429        for row in xs.chunks(n_embd) {
430            want.extend(layer.forward_row(row, &mut one.ssm, 1e-5));
431        }
432        assert_eq!(got.len(), want.len());
433        for (i, (a, b)) in got.iter().zip(&want).enumerate() {
434            assert!((a - b).abs() < 1e-6, "element {i}: {a} vs {b}");
435        }
436        assert_eq!(batched.ssm.as_ref(), one.ssm.as_ref());
437    }
438
439    /// The gate reads the LAYER INPUT, not the block output. Getting
440    /// that backwards still produces plausible numbers, so it is
441    /// pinned rather than trusted: with the gate weights zeroed the
442    /// sigmoid is 0.5 everywhere, and the answer must be exactly half
443    /// of the ungated one.
444    #[test]
445    fn the_gate_reads_the_layer_input() {
446        let (n_head, head_dim, n_embd) = (2usize, 4usize, 6usize);
447        let width = n_head * head_dim;
448        let mut zero_gate = block(n_head, head_dim, n_embd, 0, 2);
449        zero_gate.gate =
450            WeightMatrix::F32(Tensor::new(vec![0.0; width * n_embd], vec![width, n_embd]));
451        zero_gate.norm = vec![1.0; width];
452        let x: Vec<f32> = (0..n_embd).map(|i| (i as f32 * 0.2).sin()).collect();
453
454        let mut state = vec![0.0f32; Lightning::state_len(n_head, head_dim)];
455        let half = zero_gate.forward_row(&x, &mut state, 1e-5);
456
457        // The same block with the gate multiplied in by hand at 1.0.
458        let mut s2 = vec![0.0f32; Lightning::state_len(n_head, head_dim)];
459        let qkv: Vec<f32> = zero_gate.qkv.apply(&x).iter().map(|v| silu(*v)).collect();
460        let mut inner = vec![0.0f32; width];
461        for h in 0..n_head {
462            let base = h * 3 * head_dim;
463            let (slo, shi) = (h * head_dim * head_dim, (h + 1) * head_dim * head_dim);
464            let out = frink_core::lightning::lightning_step(
465                &qkv[base..base + head_dim],
466                &qkv[base + head_dim..base + 2 * head_dim],
467                &qkv[base + 2 * head_dim..base + 3 * head_dim],
468                &mut s2[slo..shi],
469                zero_gate.decay.per_step(h),
470            );
471            inner[h * head_dim..(h + 1) * head_dim].copy_from_slice(&out);
472        }
473        let ungated = zero_gate
474            .out_proj
475            .apply(&rms_norm(&inner, &zero_gate.norm, 1e-5));
476
477        for (i, (h, u)) in half.iter().zip(&ungated).enumerate() {
478            assert!(
479                (h - u * 0.5).abs() < 1e-5,
480                "element {i}: gated {h} is not half of ungated {u}"
481            );
482        }
483    }
484}