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}