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}