Skip to main content

frink_models/
attn_gate.rs

1//! The learned attention output gate: `attn_out *= act(W_g · x)` before
2//! `wo`, llama.cpp's `LLM_TENSOR_ATTN_GATE` (`blk.N.attn_gate.weight`).
3//!
4//! `afmoe`, `laguna` and `step35` each refused NEW CODE naming this
5//! tensor as what was left. The three were read side by side BEFORE
6//! being called one cause, and they are one cause with two free
7//! parameters, not one graph:
8//!
9//! | arch | gate input | activation | width | presence | lines |
10//! |---|---|---|---|---|---|
11//! | `afmoe` | `attn_norm(x)` | sigmoid | per element (`n_head * head_dim`) | required | `afmoe.cpp:73,154,183-185` |
12//! | `laguna` | `attn_norm(x)` | **softplus** | per head OR per element, by tensor shape | required | `laguna.cpp:110-124,211,246-257` |
13//! | `step35` | `attn_norm(x)` | sigmoid | per head (`n_head`) | **optional** | `step35.cpp:96,268-284` |
14//!
15//! What is IDENTICAL: the gate is projected from the same normed input
16//! the Q/K/V projections read (`attn_inp` at `afmoe.cpp:148`,
17//! `laguna.cpp:203`; `cur` at `step35.cpp:269` is the `attn_norm`
18//! output of `:221`), it multiplies the attention output AFTER the
19//! softmax-weighted V sum and BEFORE `wo`, and a per-head gate
20//! broadcasts one scalar over that head's `head_dim` channels
21//! (`ggml_reshape_3d(gate, 1, n_head, n_tokens)` then `ggml_mul`,
22//! `laguna.cpp:251-253`, `step35.cpp:276-280`). What DIFFERS is the
23//! activation (`ggml_sigmoid` vs `ggml_softplus`) and the width, and
24//! `laguna` decides the width from the stored tensor's second dimension
25//! (`:112-123`) with an abort for any other value. So the type has two
26//! axes, [`GateAct`] and [`GateWidth`], the architecture table pins the
27//! activation and the ADMISSIBLE widths, and the loader reads the width
28//! off the tensor and refuses a width the table does not admit.
29//!
30//! **Measured before built.** Six of the then-140 `src/models/*.cpp`
31//! created `LLM_TENSOR_ATTN_GATE`; re-measured on 2026-09-19 against
32//! the moved pin it is FIFTEEN of 155, and the nine new ones all
33//! landed upstream in one six-week window -- `bailingmoe3`,
34//! `dots3note`, `hrm-text`, `hy-v4`, `kimi-k3`, `minimax-01`,
35//! `muse-glimmer`, `qwen4exp`, `spark2-5`. Every one of the nine is a
36//! refusal today (`capability::NORM_ROPE_TRIAGED` /
37//! `NEOX_ROPE_TRIAGED` / the `dedicated` rows), and `spark2_5` is the
38//! cheapest row in the tree because it needs ONE line of the table
39//! below: sigmoid, per head, required, `src/models/spark2-5.cpp:41,
40//! 97-105`. A gate that is one table row away should not be a
41//! refusal for long. The other three of the original six --
42//! `qwen3next.cpp:92,335`,
43//! `qwen35.cpp:82,241`, `qwen35moe.cpp:88,265` -- store the gated
44//! delta-net's `z` projection under the same name, sized
45//! `{n_embd, value_dim}` and consumed by `build_norm_gated` on the
46//! recurrent layers; their full-attention layers gate through a
47//! double-width `wq` instead. That is `crate::gdn`'s `attn_gate`, a
48//! different op on a different engine, and it is why the table below
49//! is keyed by architecture and not by tensor presence alone.
50//!
51//! Every backend: the CPU row body ([`crate::decoder`]'s `attn_block`)
52//! and the two batched host bodies apply it through ONE function,
53//! [`AttnGate::apply_rows`]; the fused Metal attention launches fold the
54//! softmax-V product straight into `wo` with no host round-trip in
55//! between, so a layer carrying a gate is refused by the exhaustive
56//! destructure in `Decoder::metal_attn_view` rather than served without
57//! it -- the fifth thing found written into those stacks
58//! unconditionally, after the final norm, the rotation, the residual
59//! scale and the activation.
60
61use frink_core::WeightMatrix;
62use frink_gguf::TensorSource;
63
64use crate::loader::{load_weight_matrix, LoadError};
65
66/// The non-linearity the gate logits go through. Two, because the
67/// three graphs use two; a third architecture adds a variant here and
68/// a row in [`ATTN_GATE_ARCHS`], nowhere else.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum GateAct {
71    /// `1 / (1 + exp(-x))`, ggml `op_sigmoid` (`unary-ops.cpp:31-33`).
72    Sigmoid,
73    /// `x > 20 ? x : ln(1 + exp(x))`, ggml `op_softplus`
74    /// (`unary-ops.cpp:80-82`), including the branch at 20.
75    Softplus,
76}
77
78impl GateAct {
79    #[inline]
80    pub fn apply(self, x: f32) -> f32 {
81        match self {
82            GateAct::Sigmoid => 1.0 / (1.0 + (-x).exp()),
83            GateAct::Softplus => {
84                if x > 20.0 {
85                    x
86                } else {
87                    (1.0 + x.exp()).ln()
88                }
89            }
90        }
91    }
92}
93
94/// How many gate values one token gets, and therefore how they
95/// broadcast over the attention output.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum GateWidth {
98    /// `n_head` values; each scales its head's `head_dim` channels.
99    PerHead,
100    /// `n_head * head_dim` values, one per channel.
101    PerElement,
102}
103
104/// Whether the tensor may be absent.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum GatePresence {
107    /// `create_tensor(..., 0)`: llama.cpp refuses the file without it.
108    Required,
109    /// `TENSOR_NOT_REQUIRED`, and the graph tests the pointer
110    /// (`step35.cpp:268`): absent means ungated.
111    Optional,
112}
113
114/// One architecture's gate, as its llama.cpp graph spells it.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct AttnGateSpec {
117    pub act: GateAct,
118    /// Which widths the graph accepts. `laguna.cpp:116-120` aborts on
119    /// any other; `afmoe.cpp:73` and `step35.cpp:96` size the tensor
120    /// for exactly one, so any other fails their loaders' shape check.
121    pub widths: &'static [GateWidth],
122    pub presence: GatePresence,
123    /// The lines that decide the row.
124    pub lines: &'static str,
125}
126
127/// The three architectures whose graph gates its softmax attention
128/// output through `LLM_TENSOR_ATTN_GATE`. See the module doc for the
129/// three that store a different thing under the same name.
130pub const ATTN_GATE_ARCHS: &[(&str, AttnGateSpec)] = &[
131    (
132        "afmoe",
133        AttnGateSpec {
134            act: GateAct::Sigmoid,
135            widths: &[GateWidth::PerElement],
136            presence: GatePresence::Required,
137            lines: "src/models/afmoe.cpp:73,154,183-185",
138        },
139    ),
140    (
141        "laguna",
142        AttnGateSpec {
143            act: GateAct::Softplus,
144            widths: &[GateWidth::PerHead, GateWidth::PerElement],
145            presence: GatePresence::Required,
146            lines: "src/models/laguna.cpp:110-124,211,246-257",
147        },
148    ),
149    (
150        "step35",
151        AttnGateSpec {
152            act: GateAct::Sigmoid,
153            widths: &[GateWidth::PerHead],
154            presence: GatePresence::Optional,
155            lines: "src/models/step35.cpp:96,268-284",
156        },
157    ),
158    // Landed upstream after the 2026-08-04 pin and closed on
159    // 2026-09-19 as ONE row: `spark2-5.cpp:41` creates the tensor
160    // REQUIRED at `{n_embd, n_head}` and `:97-105` sigmoids it and
161    // multiplies it in per head, which is `step35`'s corner of the two
162    // axes with the presence flipped. Nothing else in that graph was
163    // new (`tests/gated_attention_graphs.rs`).
164    // `hrm-text.cpp:77-78` creates the tensor REQUIRED at
165    // `{n_embd, n_head * head_dim}` and `:113-134` sigmoid it and
166    // multiply it into the attention output PER ELEMENT before `wo`;
167    // its own comment calls it "the same shape as qwen3next attention
168    // layers", which is `afmoe`'s corner here.
169    (
170        "hrm_text",
171        AttnGateSpec {
172            act: GateAct::Sigmoid,
173            widths: &[GateWidth::PerElement],
174            presence: GatePresence::Required,
175            lines: "src/models/hrm-text.cpp:77-78,113-134",
176        },
177    ),
178    // `muse-glimmer.cpp:46` creates the tensor REQUIRED at
179    // `{n_embd, n_head * head_dim}` and `:100-135` sigmoid it and
180    // multiply it into the attention output PER ELEMENT before `wo`,
181    // which is `afmoe`'s corner; its own comment says "same as afmoe".
182    (
183        "muse-glimmer",
184        AttnGateSpec {
185            act: GateAct::Sigmoid,
186            widths: &[GateWidth::PerElement],
187            presence: GatePresence::Required,
188            lines: "src/models/muse-glimmer.cpp:46,100-135",
189        },
190    ),
191    (
192        "spark2_5",
193        AttnGateSpec {
194            act: GateAct::Sigmoid,
195            widths: &[GateWidth::PerHead],
196            presence: GatePresence::Required,
197            lines: "src/models/spark2-5.cpp:41,97-105",
198        },
199    ),
200];
201
202/// The three graphs whose FULL-attention layers gate the softmax output
203/// through a double-width `wq` (`qwen35.cpp:59,191-199,229-231`,
204/// `qwen35moe.cpp`, `qwen3next.cpp`): `attn_q` is `2 * n_head *
205/// head_dim` rows, each head's `[q, gate]` interleaved, and
206/// `sigmoid(gate) * attn` runs before `wo`. Loaded as the fused matrix
207/// (`AttnWeights::q_gate_interleaved`) and split AFTER the projection
208/// ([`split_interleaved_q_gate`]), so a quantized `wq` stays one matrix
209/// on its quantized path.
210pub const Q_INTERLEAVED_GATE_ARCHS: &[(&str, &str)] = &[
211    ("qwen35", "src/models/qwen35.cpp:59,191-199,229-231"),
212    ("qwen35moe", "src/models/qwen35moe.cpp"),
213    ("qwen3next", "src/models/qwen3next.cpp"),
214];
215
216/// True when `arch`'s full-attention layers project their gate inside
217/// `attn_q`.
218pub fn q_gate_interleaved(arch: &str) -> bool {
219    Q_INTERLEAVED_GATE_ARCHS.iter().any(|(a, _)| *a == arch)
220}
221
222/// Splits `rows` rows of a `2 * n_heads * head_dim`-wide `wq` output
223/// into the query rows (`n_heads * head_dim`) and the gate rows, per
224/// head: `qwen35.cpp:191-199` view the query at offset 0 and the gate at
225/// offset `head_dim` of each `2 * head_dim` stride.
226pub fn split_interleaved_q_gate(
227    fused: &[f32],
228    rows: usize,
229    n_heads: usize,
230    head_dim: usize,
231) -> (Vec<f32>, Vec<f32>) {
232    let width = n_heads * head_dim;
233    assert_eq!(fused.len(), rows * 2 * width);
234    let mut q = Vec::with_capacity(rows * width);
235    let mut gate = Vec::with_capacity(rows * width);
236    for r in 0..rows {
237        let row = &fused[r * 2 * width..(r + 1) * 2 * width];
238        for h in 0..n_heads {
239            q.extend_from_slice(&row[h * 2 * head_dim..h * 2 * head_dim + head_dim]);
240            gate.extend_from_slice(&row[h * 2 * head_dim + head_dim..(h + 1) * 2 * head_dim]);
241        }
242    }
243    (q, gate)
244}
245
246/// `qwen35.cpp:229-230`: `attn_out *= sigmoid(gate)`, element for
247/// element, on `rows` rows.
248pub fn apply_interleaved_gate(attn_out: &mut [f32], gate: &[f32]) {
249    assert_eq!(attn_out.len(), gate.len());
250    for (a, g) in attn_out.iter_mut().zip(gate) {
251        *a *= 1.0 / (1.0 + (-g).exp());
252    }
253}
254
255/// The three graphs that create `LLM_TENSOR_ATTN_GATE` for the gated
256/// delta-net's `z` projection instead. Recorded so the measurement
257/// behind [`ATTN_GATE_ARCHS`] is checkable, and so nobody adds them to
258/// it: their `attn_gate` is consumed by `build_norm_gated` on recurrent
259/// layers and is `crate::gdn`'s business.
260pub const GDN_Z_GATE_ARCHS: &[(&str, &str)] = &[
261    ("qwen3next", "src/models/qwen3next.cpp:92,335"),
262    ("qwen35", "src/models/qwen35.cpp:82,241"),
263    ("qwen35moe", "src/models/qwen35moe.cpp:88,265"),
264];
265
266/// The gate `arch`'s graph applies, or `None` for the 137 that apply
267/// none -- for which a file carrying `blk.N.attn_gate.weight` is
268/// refused by `loader::assert_every_tensor_consumed`, not honoured.
269pub fn attn_gate_spec(arch: &str) -> Option<AttnGateSpec> {
270    ATTN_GATE_ARCHS
271        .iter()
272        .find(|(n, _)| *n == arch)
273        .map(|(_, s)| *s)
274}
275
276/// One layer's loaded gate.
277pub struct AttnGate {
278    /// `blk.N.attn_gate.weight`, `{n_embd, n_gate_out}` on disk, so
279    /// `n_gate_out` rows of `n_embd` here.
280    pub proj: WeightMatrix,
281    pub act: GateAct,
282    pub width: GateWidth,
283}
284
285impl std::fmt::Debug for AttnGate {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        f.debug_struct("AttnGate")
288            .field(
289                "proj",
290                &format_args!("{}x{}", self.proj.rows(), self.proj.cols()),
291            )
292            .field("act", &self.act)
293            .field("width", &self.width)
294            .finish()
295    }
296}
297
298impl AttnGate {
299    /// Reads layer `l`'s gate for `arch`, deciding the width from the
300    /// tensor's row count the way `laguna.cpp:112-123` does and refusing
301    /// a width the architecture's graph does not accept.
302    ///
303    /// `Ok(None)` only for an architecture with no gate at all, or an
304    /// [`GatePresence::Optional`] one whose tensor is absent.
305    pub fn load(
306        file: &impl TensorSource,
307        arch: &str,
308        l: usize,
309        n_heads: usize,
310        head_dim: usize,
311        hidden_dim: usize,
312    ) -> Result<Option<AttnGate>, LoadError> {
313        let Some(spec) = attn_gate_spec(arch) else {
314            return Ok(None);
315        };
316        let name = format!("blk.{l}.attn_gate.weight");
317        if file.find_tensor(&name).is_none() {
318            return match spec.presence {
319                GatePresence::Optional => Ok(None),
320                GatePresence::Required => Err(LoadError::UnsupportedFeature(
321                    arch.to_string(),
322                    format!(
323                        "{name} is missing; {arch}'s graph gates its attention output through \
324                         it ({}) and llama.cpp refuses the file without it",
325                        spec.lines
326                    ),
327                )),
328            };
329        }
330        let proj = load_weight_matrix(file, &name)?;
331        let width = match proj.rows() {
332            r if r == n_heads * head_dim && spec.widths.contains(&GateWidth::PerElement) => {
333                GateWidth::PerElement
334            }
335            r if r == n_heads && spec.widths.contains(&GateWidth::PerHead) => GateWidth::PerHead,
336            r => {
337                let admissible: Vec<String> = spec
338                    .widths
339                    .iter()
340                    .map(|w| match w {
341                        GateWidth::PerHead => format!("{n_heads} (per head)"),
342                        GateWidth::PerElement => {
343                            format!("{} (per element)", n_heads * head_dim)
344                        }
345                    })
346                    .collect();
347                return Err(LoadError::UnsupportedFeature(
348                    arch.to_string(),
349                    format!(
350                        "{name} has {r} output rows; {arch}'s graph ({}) accepts {}",
351                        spec.lines,
352                        admissible.join(" or ")
353                    ),
354                ));
355            }
356        };
357        if proj.cols() != hidden_dim {
358            return Err(LoadError::UnsupportedFeature(
359                arch.to_string(),
360                format!(
361                    "{name} reads {} inputs but the hidden width is {hidden_dim}",
362                    proj.cols()
363                ),
364            ));
365        }
366        Ok(Some(AttnGate {
367            proj,
368            act: spec.act,
369            width,
370        }))
371    }
372
373    /// `attn_out[b] *= act(proj · normed[b])` for every row `b`, with a
374    /// per-head gate broadcast over each head's `head_dim` channels.
375    ///
376    /// The ONE application, called by the row body with `rows == 1`
377    /// and by the batched bodies with the whole batch, so the three
378    /// host paths cannot disagree about which input the gate reads
379    /// or which side of `wo` it sits on.
380    pub fn apply_rows(&self, normed: &[f32], attn_out: &mut [f32], rows: usize, head_dim: usize) {
381        debug_assert_eq!(normed.len(), rows * self.proj.cols());
382        let gate_width = self.proj.rows();
383        let gates = if rows == 1 {
384            self.proj.apply(normed)
385        } else {
386            self.proj.apply_batch(normed, rows)
387        };
388        debug_assert_eq!(gates.len(), rows * gate_width);
389        let out_width = attn_out.len() / rows;
390        for (row, g) in attn_out.chunks_mut(out_width).zip(gates.chunks(gate_width)) {
391            match self.width {
392                GateWidth::PerElement => {
393                    debug_assert_eq!(row.len(), g.len());
394                    for (x, &gv) in row.iter_mut().zip(g.iter()) {
395                        *x *= self.act.apply(gv);
396                    }
397                }
398                GateWidth::PerHead => {
399                    debug_assert_eq!(row.len(), g.len() * head_dim);
400                    for (head, &gv) in row.chunks_mut(head_dim).zip(g.iter()) {
401                        let s = self.act.apply(gv);
402                        for x in head.iter_mut() {
403                            *x *= s;
404                        }
405                    }
406                }
407            }
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use frink_core::Tensor;
416
417    fn gate(rows: usize, cols: usize, act: GateAct, width: GateWidth) -> AttnGate {
418        let data: Vec<f32> = (0..rows * cols).map(|i| (i as f32 * 0.37).sin()).collect();
419        AttnGate {
420            proj: WeightMatrix::F32(Tensor::new(data, vec![rows, cols])),
421            act,
422            width,
423        }
424    }
425
426    /// The two activations match ggml's scalar definitions, including
427    /// softplus's branch at 20 (`unary-ops.cpp:80-82`), which a naive
428    /// `ln(1 + exp(x))` overflows to `inf` at 89 and drifts from well
429    /// before that.
430    #[test]
431    fn the_activations_are_ggml_s() {
432        assert!((GateAct::Sigmoid.apply(0.0) - 0.5).abs() < 1e-7);
433        assert!((GateAct::Sigmoid.apply(2.0) - 0.880_797).abs() < 1e-5);
434        assert!((GateAct::Softplus.apply(0.0) - std::f32::consts::LN_2).abs() < 1e-7);
435        assert!((GateAct::Softplus.apply(-30.0)).abs() < 1e-7);
436        assert_eq!(GateAct::Softplus.apply(25.0), 25.0);
437        assert_eq!(GateAct::Softplus.apply(100.0), 100.0);
438    }
439
440    /// A per-head gate scales every channel of a head by the same
441    /// value, and a per-element gate scales each channel by its own --
442    /// checked against a hand-written loop so the broadcast cannot be
443    /// off by a head.
444    #[test]
445    fn per_head_broadcasts_over_head_dim_and_per_element_does_not() {
446        let (n_heads, head_dim, hidden) = (3, 4, 5);
447        let normed: Vec<f32> = (0..hidden).map(|i| 0.1 * i as f32 - 0.2).collect();
448        let base: Vec<f32> = (0..n_heads * head_dim).map(|i| 1.0 + i as f32).collect();
449
450        let ph = gate(n_heads, hidden, GateAct::Softplus, GateWidth::PerHead);
451        let mut out = base.clone();
452        ph.apply_rows(&normed, &mut out, 1, head_dim);
453        let g = ph.proj.apply(&normed);
454        for (h, &gh) in g.iter().enumerate() {
455            for d in 0..head_dim {
456                let i = h * head_dim + d;
457                assert!((out[i] - base[i] * GateAct::Softplus.apply(gh)).abs() < 1e-6);
458            }
459        }
460
461        let pe = gate(
462            n_heads * head_dim,
463            hidden,
464            GateAct::Sigmoid,
465            GateWidth::PerElement,
466        );
467        let mut out = base.clone();
468        pe.apply_rows(&normed, &mut out, 1, head_dim);
469        let g = pe.proj.apply(&normed);
470        for i in 0..n_heads * head_dim {
471            assert!((out[i] - base[i] * GateAct::Sigmoid.apply(g[i])).abs() < 1e-6);
472        }
473    }
474
475    /// The batched body and the row body are one function: gating two
476    /// rows at once equals gating each alone.
477    #[test]
478    fn a_batch_gates_each_row_as_the_row_body_would() {
479        let (n_heads, head_dim, hidden) = (2, 3, 4);
480        let g = gate(n_heads, hidden, GateAct::Sigmoid, GateWidth::PerHead);
481        let normed: Vec<f32> = (0..2 * hidden).map(|i| (i as f32).cos()).collect();
482        let base: Vec<f32> = (0..2 * n_heads * head_dim)
483            .map(|i| i as f32 * 0.5)
484            .collect();
485        let mut batched = base.clone();
486        g.apply_rows(&normed, &mut batched, 2, head_dim);
487        for b in 0..2 {
488            let mut row = base[b * 6..(b + 1) * 6].to_vec();
489            g.apply_rows(&normed[b * hidden..(b + 1) * hidden], &mut row, 1, head_dim);
490            assert_eq!(row, &batched[b * 6..(b + 1) * 6], "row {b}");
491        }
492    }
493
494    /// The table is keyed by architecture, every row cites its lines,
495    /// and the three GDN rows that share the tensor NAME are not in it.
496    ///
497    /// Six rows since 2026-09-19: `spark2_5`, `muse-glimmer` and
498    /// `hrm_text` all landed upstream after the 2026-08-04 pin -- the
499    /// first is `step35`'s pair with the tensor required and the other
500    /// two are `afmoe`'s.
501    #[test]
502    fn the_table_covers_the_softmax_gates_and_excludes_the_gdn_z_gates() {
503        let names: Vec<&str> = ATTN_GATE_ARCHS.iter().map(|(n, _)| *n).collect();
504        assert_eq!(
505            names,
506            [
507                "afmoe",
508                "laguna",
509                "step35",
510                "hrm_text",
511                "muse-glimmer",
512                "spark2_5"
513            ]
514        );
515        for (arch, spec) in ATTN_GATE_ARCHS {
516            assert!(spec.lines.contains(".cpp:"), "`{arch}` cites no line");
517            assert!(!spec.widths.is_empty(), "`{arch}` admits no width");
518        }
519        for (arch, _) in GDN_Z_GATE_ARCHS {
520            assert!(attn_gate_spec(arch).is_none(), "`{arch}` is a z gate");
521        }
522        assert!(attn_gate_spec("llama").is_none());
523        assert_eq!(attn_gate_spec("laguna").unwrap().act, GateAct::Softplus);
524        assert_eq!(
525            attn_gate_spec("step35").unwrap().presence,
526            GatePresence::Optional
527        );
528    }
529}