Skip to main content

ferrox_core/
attention.rs

1//! Rotary position embedding (RoPE, both the split-half `apply_rope`
2//! and interleaved `apply_rope_interleaved` conventions) and
3//! grouped-query causal attention (GQA). This is the "vanilla"
4//! attention path used as the correctness baseline.
5//! `causal_mla_attention`/`causal_mla_attention_sparse` add
6//! DeepSeek-style latent attention and its DSA sparse-selection variant
7//! (GLM-5.2, DeepSeek V3.2/V4); both mechanisms are now backed by real,
8//! public reference implementations (see docs/MODELS.md).
9//! `ferrox_models::mla`/`ferrox_models::glm_dsa` compose these
10//! primitives into full RoPE-carrying MLA forward passes.
11
12use crate::cache::PagedKvStore;
13
14/// The RoPE frequency table for `(theta, dim)`, computed once per
15/// thread.
16///
17/// `1.0 / theta.powf(2i/dim)` depends on nothing but the band index and
18/// two values that are fixed for the life of a model, yet it was
19/// evaluated inside the band loop on every call. For Llama-3-8B decode
20/// that is (32 query + 8 kv heads) x 64 bands x 32 layers, roughly 82k
21/// `powf` per token, all producing the same couple of 64-entry tables.
22///
23/// Thread-local rather than a shared map because this is a per-token
24/// path and a mutex here would trade one cost for another. The tables
25/// are tens of floats, so the duplication across worker threads is
26/// nothing.
27///
28/// The expression is unchanged, so the values are bit-identical to what
29/// the loop produced.
30fn rope_freqs(theta: f32, dim: usize) -> std::rc::Rc<Vec<f32>> {
31    /// Keyed on `(theta bits, dim)`, both of which are fixed per model.
32    type FreqTables = std::collections::HashMap<(u32, usize), std::rc::Rc<Vec<f32>>>;
33    thread_local! {
34        static CACHE: std::cell::RefCell<FreqTables> =
35            std::cell::RefCell::new(FreqTables::new());
36    }
37    // Keyed on the bit pattern: theta is a config value copied around,
38    // never computed, so equal thetas are bit-equal.
39    let key = (theta.to_bits(), dim);
40    CACHE.with(|c| {
41        if let Some(hit) = c.borrow().get(&key) {
42            return std::rc::Rc::clone(hit);
43        }
44        let table: Vec<f32> = (0..dim / 2)
45            .map(|i| 1.0 / theta.powf((2 * i) as f32 / dim as f32))
46            .collect();
47        let table = std::rc::Rc::new(table);
48        c.borrow_mut().insert(key, std::rc::Rc::clone(&table));
49        table
50    })
51}
52
53/// Applies rotary position embedding in place to a single head's vector,
54/// split-half (GPT-NeoX / `LLAMA_ROPE_TYPE_NEOX`) style: each pair
55/// `(i, i+half)` is rotated together, for position `pos` with base
56/// `theta`. This is what llama.cpp calls NEOX-style RoPE (used by e.g.
57/// DeepSeek-V3.2's lightning indexer); see [`apply_rope_interleaved`]
58/// for the other real convention.
59pub fn apply_rope(vec: &mut [f32], pos: usize, theta: f32) {
60    let dim = vec.len();
61    let half = dim / 2;
62    let freqs = rope_freqs(theta, dim);
63    for i in 0..half {
64        let freq = freqs[i];
65        let angle = pos as f32 * freq;
66        let (sin, cos) = angle.sin_cos();
67        let a = vec[i];
68        let b = vec[i + half];
69        vec[i] = a * cos - b * sin;
70        vec[i + half] = a * sin + b * cos;
71    }
72}
73
74/// Inverse of [`apply_rope`] (split-half / NeoX): rotates each pair by
75/// `-angle`. DeepSeek V4 applies this ("derope" / `ggml_rope_ext_back`)
76/// to the rope slice of attention output before the grouped `wo_a`
77/// projection — see `.scratch/NOTES_DS4_INFERENCE.md`.
78pub fn apply_rope_back(vec: &mut [f32], pos: usize, theta: f32) {
79    let dim = vec.len();
80    let half = dim / 2;
81    let freqs = rope_freqs(theta, dim);
82    for i in 0..half {
83        let freq = freqs[i];
84        let angle = pos as f32 * freq;
85        let (sin, cos) = angle.sin_cos();
86        let a = vec[i];
87        let b = vec[i + half];
88        // Inverse of (a,b) -> (a cos - b sin, a sin + b cos).
89        vec[i] = a * cos + b * sin;
90        vec[i + half] = -a * sin + b * cos;
91    }
92}
93
94/// Inverse of [`apply_rope_interleaved`] (adjacent-pair / Norm RoPE).
95pub fn apply_rope_interleaved_back(vec: &mut [f32], pos: usize, theta: f32) {
96    let dim = vec.len();
97    let half = dim / 2;
98    let freqs = rope_freqs(theta, dim);
99    for i in 0..half {
100        let freq = freqs[i];
101        let angle = pos as f32 * freq;
102        let (sin, cos) = angle.sin_cos();
103        let a = vec[2 * i];
104        let b = vec[2 * i + 1];
105        vec[2 * i] = a * cos + b * sin;
106        vec[2 * i + 1] = -a * sin + b * cos;
107    }
108}
109
110/// SIMD `q·k` for one attention head. NEON (`vfmaq_f32`) / AVX2+FMA
111/// (`_mm256_fmadd_ps`) 4-/8-wide accumulation with a scalar tail, falling
112/// back to a plain scalar sum elsewhere. The grouped accumulation
113/// reassociates the sum, so results match the scalar dot only within
114/// float noise -- which the online-softmax path already tolerates.
115#[inline]
116fn dot_f32(a: &[f32], b: &[f32]) -> f32 {
117    debug_assert_eq!(a.len(), b.len());
118    #[cfg(target_arch = "aarch64")]
119    {
120        if std::arch::is_aarch64_feature_detected!("neon") {
121            return unsafe { dot_f32_neon(a, b) };
122        }
123    }
124    #[cfg(target_arch = "x86_64")]
125    {
126        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
127            return unsafe { dot_f32_avx2(a, b) };
128        }
129    }
130    a.iter().zip(b).map(|(x, y)| x * y).sum()
131}
132
133#[cfg(target_arch = "aarch64")]
134#[target_feature(enable = "neon")]
135unsafe fn dot_f32_neon(a: &[f32], b: &[f32]) -> f32 {
136    use std::arch::aarch64::*;
137    let n = a.len();
138    let mut acc = vdupq_n_f32(0.0);
139    let mut i = 0;
140    while i + 4 <= n {
141        let va = vld1q_f32(a.as_ptr().add(i));
142        let vb = vld1q_f32(b.as_ptr().add(i));
143        acc = vfmaq_f32(acc, va, vb);
144        i += 4;
145    }
146    let mut sum = vaddvq_f32(acc);
147    while i < n {
148        sum += a[i] * b[i];
149        i += 1;
150    }
151    sum
152}
153
154#[cfg(target_arch = "x86_64")]
155#[target_feature(enable = "avx2,fma")]
156unsafe fn dot_f32_avx2(a: &[f32], b: &[f32]) -> f32 {
157    use std::arch::x86_64::*;
158    let n = a.len();
159    let mut acc = _mm256_setzero_ps();
160    let mut i = 0;
161    while i + 8 <= n {
162        let va = _mm256_loadu_ps(a.as_ptr().add(i));
163        let vb = _mm256_loadu_ps(b.as_ptr().add(i));
164        acc = _mm256_fmadd_ps(va, vb, acc);
165        i += 8;
166    }
167    let mut sum = hsum256_ps(acc);
168    while i < n {
169        sum += a[i] * b[i];
170        i += 1;
171    }
172    sum
173}
174
175/// Horizontal sum of an AVX2 f32 vector. Factored out of
176/// [`dot_f32_avx2`] so [`qk_tile_avx2`] can close its register tile with
177/// the *same* reduction, which is what keeps the tiled scores
178/// bit-identical to the row-at-a-time ones.
179#[cfg(target_arch = "x86_64")]
180#[target_feature(enable = "avx2")]
181unsafe fn hsum256_ps(acc: std::arch::x86_64::__m256) -> f32 {
182    use std::arch::x86_64::*;
183    let lo = _mm256_castps256_ps128(acc);
184    let hi = _mm256_extractf128_ps(acc, 1);
185    let mut s128 = _mm_add_ps(lo, hi);
186    s128 = _mm_add_ps(s128, _mm_movehl_ps(s128, s128));
187    s128 = _mm_add_ss(s128, _mm_shuffle_ps(s128, s128, 0x55));
188    _mm_cvtss_f32(s128)
189}
190
191/// Online (flash-style) softmax·V accumulate for one head: one pass over
192/// K/V, no `seq_len` score buffer. Numerically matches classic
193/// max-subtract softmax within float noise (see unit tests).
194///
195/// When `attn_softcap` is `Some(sc)` with `sc > 0`, each score is remapped
196/// with Gemma-2-style `sc * tanh(score / sc)` before the online softmax
197/// (llama.cpp `attention.logit_softcapping`).
198///
199/// When `sink` is `Some(s)`, one extra virtual key participates in the
200/// softmax denominator with logit `s` and a **zero** value vector, so it
201/// bleeds probability mass away from the real keys without contributing
202/// anything to the output. That is gpt-oss's attention sink, and this is
203/// exactly llama.cpp's own online form
204/// (`ggml/src/ggml-cpu/ops.cpp`, `ggml_compute_forward_flash_attn_ext_f16`,
205/// the `// sinks - apply only on the first kv-chunk` block):
206///
207/// ```text
208/// if (s > M) { ms = expf(M - s); M = s; scale VKQ by ms; } else { vs = expf(s - M); }
209/// S = S*ms + vs;
210/// ```
211///
212/// The sink logit is *not* multiplied by `scale` — it is a learned logit
213/// already in score space, matching both the flash-attention path above
214/// and `ggml_compute_forward_soft_max_f32`, which applies `scale` to the
215/// KQ row before taking `MAX(max, sk[head])`.
216fn online_attn_accumulate(
217    q_h: &[f32],
218    scale: f32,
219    head_dim: usize,
220    out_h: &mut [f32],
221    attn_softcap: Option<f32>,
222    sink: Option<f32>,
223    mut for_each_kv: impl FnMut(&mut dyn FnMut(&[f32], &[f32])),
224) {
225    debug_assert_eq!(q_h.len(), head_dim);
226    debug_assert_eq!(out_h.len(), head_dim);
227    let mut m = f32::NEG_INFINITY;
228    let mut l = 0f32;
229    out_h.fill(0.0);
230    for_each_kv(&mut |k_t, v_t| {
231        let mut s = dot_f32(q_h, k_t) * scale;
232        if let Some(sc) = attn_softcap.filter(|&c| c > 0.0) {
233            s = sc * (s / sc).tanh();
234        }
235        let m_new = m.max(s);
236        let alpha = (m - m_new).exp();
237        let p = (s - m_new).exp();
238        l = l * alpha + p;
239        axpy_scale(out_h, alpha, v_t, p);
240        m = m_new;
241    });
242    if let Some(s) = sink {
243        let m_new = m.max(s);
244        let alpha = (m - m_new).exp();
245        l = l * alpha + (s - m_new).exp();
246        scale_inplace(out_h, alpha);
247    }
248    if l > 0.0 {
249        let inv = 1.0 / l;
250        scale_inplace(out_h, inv);
251    }
252}
253
254/// `out[i] = out[i] * alpha + p * v[i]` (online-softmax V accumulate).
255#[inline]
256fn axpy_scale(out: &mut [f32], alpha: f32, v: &[f32], p: f32) {
257    debug_assert_eq!(out.len(), v.len());
258    #[cfg(target_arch = "aarch64")]
259    {
260        if std::arch::is_aarch64_feature_detected!("neon") {
261            unsafe { axpy_scale_neon(out, alpha, v, p) };
262            return;
263        }
264    }
265    #[cfg(target_arch = "x86_64")]
266    {
267        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
268            unsafe { axpy_scale_avx2(out, alpha, v, p) };
269            return;
270        }
271    }
272    for (o, &vv) in out.iter_mut().zip(v) {
273        *o = *o * alpha + p * vv;
274    }
275}
276
277/// `out[i] += p * v[i]` (plain axpy; the blocked-softmax V accumulate,
278/// which never rescales what is already accumulated).
279#[inline]
280fn axpy(out: &mut [f32], v: &[f32], p: f32) {
281    debug_assert_eq!(out.len(), v.len());
282    #[cfg(target_arch = "aarch64")]
283    {
284        if std::arch::is_aarch64_feature_detected!("neon") {
285            unsafe { axpy_neon(out, v, p) };
286            return;
287        }
288    }
289    #[cfg(target_arch = "x86_64")]
290    {
291        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
292            unsafe { axpy_avx2(out, v, p) };
293            return;
294        }
295    }
296    for (o, &vv) in out.iter_mut().zip(v) {
297        *o += p * vv;
298    }
299}
300
301#[cfg(target_arch = "aarch64")]
302#[target_feature(enable = "neon")]
303unsafe fn axpy_neon(out: &mut [f32], v: &[f32], p: f32) {
304    use std::arch::aarch64::*;
305    let n = out.len();
306    let vp = vdupq_n_f32(p);
307    let mut i = 0;
308    while i + 4 <= n {
309        let o = vld1q_f32(out.as_ptr().add(i));
310        let vv = vld1q_f32(v.as_ptr().add(i));
311        vst1q_f32(out.as_mut_ptr().add(i), vfmaq_f32(o, vv, vp));
312        i += 4;
313    }
314    while i < n {
315        out[i] += p * v[i];
316        i += 1;
317    }
318}
319
320#[cfg(target_arch = "x86_64")]
321#[target_feature(enable = "avx2,fma")]
322unsafe fn axpy_avx2(out: &mut [f32], v: &[f32], p: f32) {
323    use std::arch::x86_64::*;
324    let n = out.len();
325    let vp = _mm256_set1_ps(p);
326    let mut i = 0;
327    while i + 8 <= n {
328        let o = _mm256_loadu_ps(out.as_ptr().add(i));
329        let vv = _mm256_loadu_ps(v.as_ptr().add(i));
330        _mm256_storeu_ps(out.as_mut_ptr().add(i), _mm256_fmadd_ps(vv, vp, o));
331        i += 8;
332    }
333    while i < n {
334        out[i] += p * v[i];
335        i += 1;
336    }
337}
338
339#[inline]
340fn scale_inplace(x: &mut [f32], s: f32) {
341    #[cfg(target_arch = "aarch64")]
342    {
343        if std::arch::is_aarch64_feature_detected!("neon") {
344            unsafe { scale_inplace_neon(x, s) };
345            return;
346        }
347    }
348    #[cfg(target_arch = "x86_64")]
349    {
350        if std::is_x86_feature_detected!("avx2") {
351            unsafe { scale_inplace_avx2(x, s) };
352            return;
353        }
354    }
355    for v in x.iter_mut() {
356        *v *= s;
357    }
358}
359
360#[cfg(target_arch = "aarch64")]
361#[target_feature(enable = "neon")]
362unsafe fn axpy_scale_neon(out: &mut [f32], alpha: f32, v: &[f32], p: f32) {
363    use std::arch::aarch64::*;
364    let n = out.len();
365    let va = vdupq_n_f32(alpha);
366    let vp = vdupq_n_f32(p);
367    let mut i = 0;
368    while i + 4 <= n {
369        let o = vld1q_f32(out.as_ptr().add(i));
370        let vv = vld1q_f32(v.as_ptr().add(i));
371        let r = vfmaq_f32(vmulq_f32(o, va), vv, vp);
372        vst1q_f32(out.as_mut_ptr().add(i), r);
373        i += 4;
374    }
375    while i < n {
376        out[i] = out[i] * alpha + p * v[i];
377        i += 1;
378    }
379}
380
381#[cfg(target_arch = "aarch64")]
382#[target_feature(enable = "neon")]
383unsafe fn scale_inplace_neon(x: &mut [f32], s: f32) {
384    use std::arch::aarch64::*;
385    let n = x.len();
386    let vs = vdupq_n_f32(s);
387    let mut i = 0;
388    while i + 4 <= n {
389        let v = vld1q_f32(x.as_ptr().add(i));
390        vst1q_f32(x.as_mut_ptr().add(i), vmulq_f32(v, vs));
391        i += 4;
392    }
393    while i < n {
394        x[i] *= s;
395        i += 1;
396    }
397}
398
399#[cfg(target_arch = "x86_64")]
400#[target_feature(enable = "avx2,fma")]
401unsafe fn axpy_scale_avx2(out: &mut [f32], alpha: f32, v: &[f32], p: f32) {
402    use std::arch::x86_64::*;
403    let n = out.len();
404    let va = _mm256_set1_ps(alpha);
405    let vp = _mm256_set1_ps(p);
406    let mut i = 0;
407    while i + 8 <= n {
408        let o = _mm256_loadu_ps(out.as_ptr().add(i));
409        let vv = _mm256_loadu_ps(v.as_ptr().add(i));
410        let r = _mm256_fmadd_ps(vv, vp, _mm256_mul_ps(o, va));
411        _mm256_storeu_ps(out.as_mut_ptr().add(i), r);
412        i += 8;
413    }
414    while i < n {
415        out[i] = out[i] * alpha + p * v[i];
416        i += 1;
417    }
418}
419
420#[cfg(target_arch = "x86_64")]
421#[target_feature(enable = "avx2")]
422unsafe fn scale_inplace_avx2(x: &mut [f32], s: f32) {
423    use std::arch::x86_64::*;
424    let n = x.len();
425    let vs = _mm256_set1_ps(s);
426    let mut i = 0;
427    while i + 8 <= n {
428        let v = _mm256_loadu_ps(x.as_ptr().add(i));
429        _mm256_storeu_ps(x.as_mut_ptr().add(i), _mm256_mul_ps(v, vs));
430        i += 8;
431    }
432    while i < n {
433        x[i] *= s;
434        i += 1;
435    }
436}
437
438/// Same split-half rotation as [`apply_rope`], but each frequency band
439/// `i` has its angle divided by `freq_factors[i]` before the rotation --
440/// Llama 3/3.1/3.2's real per-band RoPE frequency correction (the
441/// `rope_freqs.weight` GGUF tensor, `n_rot/2` elements, `TENSOR_NOT_REQUIRED`
442/// so most non-Llama-3 checkpoints don't carry it). Confirmed against
443/// real llama.cpp source, not guessed: `ggml_rope_cache_init`
444/// (`ggml/src/ggml-cpu/ops.cpp`) computes `theta/freq_factors[i0/2]`
445/// per band before `rope_yarn`. `freq_factors` all-`1.0` is
446/// mathematically identical to plain `apply_rope` (pinned by
447/// `rope_with_all_ones_freq_factors_matches_plain_rope`).
448///
449/// Found via a real end-to-end run: serving a real
450/// Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf checkpoint (this tensor
451/// present and non-trivial) produced short answers correctly but
452/// degenerated into a spurious early EOS a few dozen tokens into a
453/// longer generation -- a real, independent llama.cpp oracle run
454/// against the exact same file continued correctly with no early stop.
455/// Root cause: every RoPE angle was computed without this per-band
456/// correction, an error that compounds with position and eventually
457/// produces wrong logits.
458pub fn apply_rope_with_freq_factors(vec: &mut [f32], pos: usize, theta: f32, freq_factors: &[f32]) {
459    let dim = vec.len();
460    let half = dim / 2;
461    assert_eq!(
462        freq_factors.len(),
463        half,
464        "freq_factors must have one entry per rotation band (dim/2)"
465    );
466    let freqs = rope_freqs(theta, dim);
467    for i in 0..half {
468        let freq = freqs[i];
469        let angle = pos as f32 * freq / freq_factors[i];
470        let (sin, cos) = angle.sin_cos();
471        let a = vec[i];
472        let b = vec[i + half];
473        vec[i] = a * cos - b * sin;
474        vec[i + half] = a * sin + b * cos;
475    }
476}
477
478/// Applies rotary position embedding in place, interleaved (GPT-J /
479/// llama.cpp's `LLAMA_ROPE_TYPE_NORM`) style: adjacent pairs
480/// `(2*i, 2*i+1)` are rotated together, rather than `apply_rope`'s
481/// split-half pairing. GLM-5.2 uses this convention for both its main
482/// attention (`rope_interleave: true`) and its lightning indexer
483/// (`indexer_rope_interleave: true`) per its real `config.json`
484/// (`huggingface.co/zai-org/GLM-5.2`) — confirmed against llama.cpp PR
485/// #25407, which rotates the indexer with `LLAMA_ROPE_TYPE_NORM` where
486/// DeepSeek-V3.2's PR #23346 uses `LLAMA_ROPE_TYPE_NEOX`.
487pub fn apply_rope_interleaved(vec: &mut [f32], pos: usize, theta: f32) {
488    let dim = vec.len();
489    let half = dim / 2;
490    let freqs = rope_freqs(theta, dim);
491    for i in 0..half {
492        let freq = freqs[i];
493        let angle = pos as f32 * freq;
494        let (sin, cos) = angle.sin_cos();
495        let a = vec[2 * i];
496        let b = vec[2 * i + 1];
497        vec[2 * i] = a * cos - b * sin;
498        vec[2 * i + 1] = a * sin + b * cos;
499    }
500}
501
502/// Interleaved (GPT-J / `LLAMA_ROPE_TYPE_NORM`) RoPE with Llama 3/3.1/3.2's
503/// per-band frequency correction -- the combination real llama.cpp uses
504/// for `general.architecture = "llama"` checkpoints that carry
505/// `rope_freqs.weight`. Pairing is adjacent `(2*i, 2*i+1)` as in
506/// [`apply_rope_interleaved`]; each band's angle is divided by
507/// `freq_factors[i]` as in [`apply_rope_with_freq_factors`].
508/// `freq_factors` all-`1.0` is mathematically identical to plain
509/// `apply_rope_interleaved` (pinned by
510/// `rope_interleaved_with_all_ones_freq_factors_matches_plain_interleaved`).
511pub fn apply_rope_interleaved_with_freq_factors(
512    vec: &mut [f32],
513    pos: usize,
514    theta: f32,
515    freq_factors: &[f32],
516) {
517    let dim = vec.len();
518    let half = dim / 2;
519    assert_eq!(
520        freq_factors.len(),
521        half,
522        "freq_factors must have one entry per rotation band (dim/2)"
523    );
524    let freqs = rope_freqs(theta, dim);
525    for i in 0..half {
526        let freq = freqs[i];
527        let angle = pos as f32 * freq / freq_factors[i];
528        let (sin, cos) = angle.sin_cos();
529        let a = vec[2 * i];
530        let b = vec[2 * i + 1];
531        vec[2 * i] = a * cos - b * sin;
532        vec[2 * i + 1] = a * sin + b * cos;
533    }
534}
535
536/// YaRN RoPE scaling exactly as a checkpoint declares it, in the shape
537/// the reference reads out of `rope_scaling` (FreeToken
538/// `python/freetoken/layers/rotary.py:139`, the `"yarn"` arm of
539/// `_get_rope`). `beta_fast` / `beta_slow` / `truncate` carry that arm's
540/// own defaults, because a real YaRN checkpoint usually declares only
541/// `factor` and `original_max_position_embeddings`.
542///
543/// This type is only the declaration. The frequency rewrite it implies
544/// is [`yarn_freq_factors`], whose output feeds
545/// [`apply_rope_with_freq_factors`] /
546/// [`apply_rope_interleaved_with_freq_factors`] like any other per-band
547/// correction.
548#[derive(Debug, Clone, Copy, PartialEq)]
549pub struct YarnScaling {
550    /// `rope_scaling["factor"]`: how much longer the served context is
551    /// than the context the checkpoint was trained at. `1.0` makes the
552    /// whole rewrite a no-op (every band's divisor is `1.0`).
553    pub factor: f32,
554    /// `rope_scaling["beta_fast"]`, the number of full rotations that
555    /// marks the *high*-frequency end of the correction ramp -- bands
556    /// faster than this are left extrapolated. Reference default `32.0`.
557    pub beta_fast: f32,
558    /// `rope_scaling["beta_slow"]`, the rotation count marking the
559    /// *low*-frequency end -- bands slower than this are fully
560    /// interpolated. Reference default `1.0`.
561    pub beta_slow: f32,
562    /// `rope_scaling["original_max_position_embeddings"]`: the context
563    /// the checkpoint was actually trained at, which is the length the
564    /// rotation counts above are counted against.
565    pub orig_max_pos: usize,
566    /// `rope_scaling["truncate"]`, reference default `true`: floor the
567    /// low end and ceil the high end of the correction range to whole
568    /// band indices. `false` keeps the fractional range, which is the
569    /// case the `low == high` nudge in [`yarn_correction_range`] exists
570    /// for.
571    pub truncate: bool,
572}
573
574impl YarnScaling {
575    /// The reference's defaults for everything a checkpoint may omit
576    /// (`rope_scaling.get("beta_fast", 32.0)`,
577    /// `.get("beta_slow", 1.0)`, `.get("truncate", True)`). Using
578    /// anything else here silently moves the correction range and
579    /// therefore which dims extrapolate.
580    pub fn new(factor: f32, orig_max_pos: usize) -> Self {
581        YarnScaling {
582            factor,
583            beta_fast: 32.0,
584            beta_slow: 1.0,
585            orig_max_pos,
586            truncate: true,
587        }
588    }
589}
590
591/// The (fractional) band index at which a frequency completes exactly
592/// `num_rotations` full rotations over the checkpoint's *original*
593/// trained context -- the reference's `_find_correction_dim`
594/// (`rotary.py:161`):
595/// `rotary_dim * ln(orig_max_pos / (num_rotations * 2π)) / (2 * ln(base))`.
596///
597/// Computed in `f64`: `ln` of a 128k context over `2 * ln(base)` is a
598/// ratio of two large logs, and doing it in `f32` moves the floored /
599/// ceiled band index by a whole band often enough to matter.
600fn yarn_correction_dim(
601    num_rotations: f64,
602    rotary_dim: usize,
603    base: f64,
604    orig_max_pos: usize,
605) -> f64 {
606    rotary_dim as f64 * (orig_max_pos as f64 / (num_rotations * 2.0 * std::f64::consts::PI)).ln()
607        / (2.0 * base.ln())
608}
609
610/// The `[low, high]` band range the YaRN ramp interpolates across, as
611/// the reference computes it (`rotary.py:167-179`): both ends from
612/// [`yarn_correction_dim`], floored / ceiled when `truncate`, `low`
613/// clamped up to `0`, and -- the load-bearing detail, called out in the
614/// reference's own comment at `rotary.py:176` -- `high` clamped to
615/// **`rotary_dim - 1`, not `rotary_dim / 2 - 1`**.
616///
617/// The ramp only has `rotary_dim / 2` entries, so a `high` above
618/// `rotary_dim / 2 - 1` means the ramp never reaches `1.0`: the
619/// longest-wavelength dims stay *partly* extrapolated. Clamping to the
620/// ramp's own last index instead (the naive reading) forces the ramp to
621/// hit `1.0` at the last band and fully interpolates dims the reference
622/// deliberately leaves partly extrapolated -- a checkpoint-wide change
623/// to the lowest frequencies, i.e. exactly the dims long-context
624/// behaviour rides on. Pinned by
625/// `yarn_high_is_clamped_to_rotary_dim_minus_one_not_half_minus_one`.
626///
627/// Returned as `f64` because `high` may be fractional: when the range
628/// collapses (`low == high`, which is what `truncate: false` with
629/// `beta_fast == beta_slow` produces) the reference nudges `high` by
630/// `+0.001` rather than flooring the gap at `1`, and that nudge is what
631/// makes the ramp a step at `low` instead of a division by zero.
632pub fn yarn_correction_range(scaling: YarnScaling, rotary_dim: usize, base: f32) -> (f64, f64) {
633    let base = base as f64;
634    let mut low = yarn_correction_dim(
635        scaling.beta_fast as f64,
636        rotary_dim,
637        base,
638        scaling.orig_max_pos,
639    );
640    let mut high = yarn_correction_dim(
641        scaling.beta_slow as f64,
642        rotary_dim,
643        base,
644        scaling.orig_max_pos,
645    );
646    if scaling.truncate {
647        low = low.floor();
648        high = high.ceil();
649    }
650    low = low.max(0.0);
651    high = high.min(rotary_dim as f64 - 1.0);
652    if low == high {
653        high += 0.001;
654    }
655    (low, high)
656}
657
658/// YaRN's frequency rewrite, expressed as the per-band **divisors**
659/// [`apply_rope_with_freq_factors`] already consumes: one entry per
660/// rotation band (`rotary_dim / 2`), each the number the band's RoPE
661/// angle is divided by.
662///
663/// The reference rewrites the frequencies themselves
664/// (`rotary.py:181-187`):
665/// `inv_freq_new = (inv_freq / factor) * ramp + inv_freq * (1 - ramp)`
666/// with `ramp = clamp((band - low) / (high - low), 0, 1)` over
667/// `rotary_dim / 2` bands. Dividing an angle by `d` is scaling its
668/// frequency by `1/d`, so the identical rewrite in divisor form is
669/// `d = 1 / (ramp / factor + (1 - ramp))` -- exactly `1.0` on the
670/// extrapolated (fast) bands and exactly `factor` on any fully
671/// interpolated one. Keeping it in this form is what lets a YaRN
672/// checkpoint ride the *existing* CPU and Metal RoPE paths (llama.cpp's
673/// `rope_freqs` semantics: `theta / freq_factors[i]`) instead of needing
674/// a second rotation kernel; it also composes with a checkpoint's own
675/// per-band factors by multiplication.
676///
677/// Skipping this rewrite entirely -- what ferrox did before this
678/// existed, since it read neither `rope.scaling.type` nor
679/// `rope.scaling.factor` -- ropes a long-context YaRN checkpoint as if
680/// it declared no scaling at all: correct near position 0 and
681/// progressively wrong with position, which is the failure that looks
682/// like quality "degrading over long prompts" rather than like a bug.
683///
684/// # Panics
685/// If `rotary_dim` is odd or zero (a band would have no partner
686/// channel), or `factor` is not positive (the divisor would be
687/// non-finite and every angle with it).
688pub fn yarn_freq_factors(scaling: YarnScaling, rotary_dim: usize, base: f32) -> Vec<f32> {
689    assert!(
690        rotary_dim > 0 && rotary_dim.is_multiple_of(2),
691        "rotary_dim must be a positive even number of channels, got {rotary_dim}"
692    );
693    assert!(
694        scaling.factor > 0.0,
695        "YaRN factor must be positive, got {}",
696        scaling.factor
697    );
698    let (low, high) = yarn_correction_range(scaling, rotary_dim, base);
699    let factor = scaling.factor as f64;
700    (0..rotary_dim / 2)
701        .map(|band| {
702            let ramp = ((band as f64 - low) / (high - low)).clamp(0.0, 1.0);
703            // Reference form: inv_freq * (ramp / factor + (1 - ramp)).
704            let freq_scale = ramp / factor + (1.0 - ramp);
705            (1.0 / freq_scale) as f32
706        })
707        .collect()
708}
709
710/// The reference's `"proportional"` arm (`rotary.py:103`), in the same
711/// per-band divisor form as [`yarn_freq_factors`].
712///
713/// Partial rope normally spaces its frequencies over the *rotated*
714/// width (`base^(2i / rotary_dim)`, what [`apply_rope`] and friends
715/// compute from the slice they are handed). The proportional arm spaces
716/// them over the **full head** instead (`base^(2i / head_size)`) and
717/// zeroes every band past `rotary_dim / 2` -- i.e. the untouched tail of
718/// the head is exactly the tail this crate already leaves unrotated, so
719/// only the spacing differs. The returned divisor,
720/// `base^(2i/head_size - 2i/rotary_dim)`, converts one spacing into the
721/// other and is all-`1.0` when `rotary_dim == head_size` (full rope,
722/// where the two spacings coincide).
723///
724/// Using the wrong spacing for a checkpoint that declares this is not a
725/// long-context-only error: every rotated band below the last is turned
726/// at the wrong rate from position 1 onward.
727///
728/// # Panics
729/// If `rotary_dim` is odd, zero, or wider than `head_size`.
730pub fn proportional_freq_factors(head_size: usize, rotary_dim: usize, base: f32) -> Vec<f32> {
731    assert!(
732        rotary_dim > 0 && rotary_dim.is_multiple_of(2) && rotary_dim <= head_size,
733        "rotary_dim {rotary_dim} must be positive, even, and no wider than head_size {head_size}"
734    );
735    let base = base as f64;
736    (0..rotary_dim / 2)
737        .map(|band| {
738            let exponent =
739                (2 * band) as f64 / head_size as f64 - (2 * band) as f64 / rotary_dim as f64;
740            base.powf(exponent) as f32
741        })
742        .collect()
743}
744
745/// Single-token causal attention for one query against all cached
746/// key/value positions (0..=pos), grouped-query style: `n_kv_heads` may
747/// be fewer than `n_heads`, with each KV head shared by
748/// `n_heads / n_kv_heads` query heads.
749///
750/// `q` is [n_heads, head_dim]; `k_cache`/`v_cache` are
751/// [seq_len, n_kv_heads, head_dim] flattened row-major. Returns
752/// [n_heads, head_dim].
753pub fn causal_gqa_attention(
754    q: &[f32],
755    k_cache: &[f32],
756    v_cache: &[f32],
757    n_heads: usize,
758    n_kv_heads: usize,
759    head_dim: usize,
760    seq_len: usize,
761) -> Vec<f32> {
762    causal_gqa_attention_softcap(
763        q, k_cache, v_cache, n_heads, n_kv_heads, head_dim, seq_len, None,
764    )
765}
766
767/// [`causal_gqa_attention`] with optional Gemma-2 attention logit softcap.
768#[allow(clippy::too_many_arguments)]
769pub fn causal_gqa_attention_softcap(
770    q: &[f32],
771    k_cache: &[f32],
772    v_cache: &[f32],
773    n_heads: usize,
774    n_kv_heads: usize,
775    head_dim: usize,
776    seq_len: usize,
777    attn_softcap: Option<f32>,
778) -> Vec<f32> {
779    assert_eq!(q.len(), n_heads * head_dim);
780    assert_eq!(k_cache.len(), seq_len * n_kv_heads * head_dim);
781    assert_eq!(v_cache.len(), seq_len * n_kv_heads * head_dim);
782
783    let group_size = n_heads / n_kv_heads.max(1);
784    let scale = 1.0 / (head_dim as f32).sqrt();
785    let mut out = vec![0f32; n_heads * head_dim];
786
787    for h in 0..n_heads {
788        let kv_h = h / group_size.max(1);
789        let q_h = &q[h * head_dim..(h + 1) * head_dim];
790        let out_h = &mut out[h * head_dim..(h + 1) * head_dim];
791        online_attn_accumulate(q_h, scale, head_dim, out_h, attn_softcap, None, |visit| {
792            for t in 0..seq_len {
793                let k_t = &k_cache
794                    [(t * n_kv_heads + kv_h) * head_dim..(t * n_kv_heads + kv_h + 1) * head_dim];
795                let v_t = &v_cache
796                    [(t * n_kv_heads + kv_h) * head_dim..(t * n_kv_heads + kv_h + 1) * head_dim];
797                visit(k_t, v_t);
798            }
799        });
800    }
801
802    out
803}
804
805/// Same computation as `causal_gqa_attention`, but each query only
806/// attends to the last `window` cached positions (inclusive of itself)
807/// instead of the full causal history -- Mistral/Mixtral/Qwen2-family
808/// sliding-window attention. Confirmed against the real
809/// `sliding_window` config field used by those models (real
810/// `transformers` source for `Qwen2MoeAttention`/Mixtral's equivalent)
811/// and against candle-transformers' `mixtral.rs`/`qwen2_moe.rs`, which
812/// both mask scores where `key_pos + sliding_window < query_pos` --
813/// i.e. only the most recent `window` positions (including the
814/// query's own) stay unmasked. `window >= seq_len` degenerates to
815/// exactly `causal_gqa_attention`'s full-causal behavior (pinned by
816/// `windowed_attention_with_window_covering_full_history_matches_full_causal`).
817#[allow(clippy::too_many_arguments)]
818pub fn causal_gqa_attention_windowed(
819    q: &[f32],
820    k_cache: &[f32],
821    v_cache: &[f32],
822    n_heads: usize,
823    n_kv_heads: usize,
824    head_dim: usize,
825    seq_len: usize,
826    window: usize,
827) -> Vec<f32> {
828    causal_gqa_attention_windowed_softcap(
829        q, k_cache, v_cache, n_heads, n_kv_heads, head_dim, seq_len, window, None,
830    )
831}
832
833/// [`causal_gqa_attention_windowed`] with optional attention logit softcap.
834#[allow(clippy::too_many_arguments)]
835pub fn causal_gqa_attention_windowed_softcap(
836    q: &[f32],
837    k_cache: &[f32],
838    v_cache: &[f32],
839    n_heads: usize,
840    n_kv_heads: usize,
841    head_dim: usize,
842    seq_len: usize,
843    window: usize,
844    attn_softcap: Option<f32>,
845) -> Vec<f32> {
846    assert_eq!(q.len(), n_heads * head_dim);
847    assert_eq!(k_cache.len(), seq_len * n_kv_heads * head_dim);
848    assert_eq!(v_cache.len(), seq_len * n_kv_heads * head_dim);
849    assert!(window > 0, "window must be positive");
850
851    let group_size = n_heads / n_kv_heads.max(1);
852    let scale = 1.0 / (head_dim as f32).sqrt();
853    let mut out = vec![0f32; n_heads * head_dim];
854    // The current query is the last position in the cache (position
855    // seq_len - 1); only the most recent `window` positions, including
856    // this one, are visible.
857    let window_start = seq_len.saturating_sub(window);
858
859    for h in 0..n_heads {
860        let kv_h = h / group_size.max(1);
861        let q_h = &q[h * head_dim..(h + 1) * head_dim];
862        let out_h = &mut out[h * head_dim..(h + 1) * head_dim];
863        online_attn_accumulate(q_h, scale, head_dim, out_h, attn_softcap, None, |visit| {
864            for t in window_start..seq_len {
865                let k_t = &k_cache
866                    [(t * n_kv_heads + kv_h) * head_dim..(t * n_kv_heads + kv_h + 1) * head_dim];
867                let v_t = &v_cache
868                    [(t * n_kv_heads + kv_h) * head_dim..(t * n_kv_heads + kv_h + 1) * head_dim];
869                visit(k_t, v_t);
870            }
871        });
872    }
873
874    out
875}
876
877/// Single-query causal GQA with per-head **attention sinks**, optionally
878/// windowed.
879///
880/// `sinks` is one learned logit per *query* head (gpt-oss ships it as
881/// `blk.N.attn_sinks.weight`, length `n_heads`). It joins the softmax
882/// denominator without contributing a value vector, which lets a head
883/// attend to "nothing" instead of being forced to spend its whole
884/// probability mass on real tokens — see [`online_attn_accumulate`] for
885/// the exact llama.cpp form this reproduces.
886///
887/// `window` is `Some(w)` for a sliding-window layer (the query sees only
888/// the last `w` cached positions, itself included, exactly as
889/// [`causal_gqa_attention_windowed`]) and `None` for full causal
890/// attention. gpt-oss alternates the two per layer.
891///
892/// Deliberately one function covering both, and deliberately the
893/// single-query shape: prefill drives it once per query position. That
894/// is slower than the blocked prefill kernel and is the honest trade —
895/// one code path whose numerics are checked against llama.cpp beats
896/// three that are not.
897#[allow(clippy::too_many_arguments)]
898pub fn causal_gqa_attention_sinks(
899    q: &[f32],
900    k_cache: &[f32],
901    v_cache: &[f32],
902    n_heads: usize,
903    n_kv_heads: usize,
904    head_dim: usize,
905    seq_len: usize,
906    window: Option<usize>,
907    sinks: &[f32],
908) -> Vec<f32> {
909    assert_eq!(q.len(), n_heads * head_dim);
910    assert_eq!(k_cache.len(), seq_len * n_kv_heads * head_dim);
911    assert_eq!(v_cache.len(), seq_len * n_kv_heads * head_dim);
912    assert_eq!(
913        sinks.len(),
914        n_heads,
915        "attention sinks are per query head (llama.cpp `attn_sinks` is {{n_head}})"
916    );
917
918    let group_size = n_heads / n_kv_heads.max(1);
919    let scale = 1.0 / (head_dim as f32).sqrt();
920    let mut out = vec![0f32; n_heads * head_dim];
921    // The query is the last cached position; a windowed layer sees only
922    // the most recent `window` positions including its own.
923    let start = match window {
924        Some(w) => {
925            assert!(w > 0, "window must be positive");
926            seq_len.saturating_sub(w)
927        }
928        None => 0,
929    };
930
931    for h in 0..n_heads {
932        let kv_h = h / group_size.max(1);
933        let q_h = &q[h * head_dim..(h + 1) * head_dim];
934        let sink = sinks[h];
935        let out_h = &mut out[h * head_dim..(h + 1) * head_dim];
936        online_attn_accumulate(q_h, scale, head_dim, out_h, None, Some(sink), |visit| {
937            for t in start..seq_len {
938                let k_t = &k_cache
939                    [(t * n_kv_heads + kv_h) * head_dim..(t * n_kv_heads + kv_h + 1) * head_dim];
940                let v_t = &v_cache
941                    [(t * n_kv_heads + kv_h) * head_dim..(t * n_kv_heads + kv_h + 1) * head_dim];
942                visit(k_t, v_t);
943            }
944        });
945    }
946
947    out
948}
949
950/// Prefill (multi-query) causal GQA: `q`/`k_cache`/`v_cache` are all length
951/// `seq_len` in the time dimension. Query at position `t` attends only to
952/// keys/values `0..=t` (same math as looping [`causal_gqa_attention`] per
953/// token). Layout: q/out `[seq_len, n_heads, head_dim]`; k/v
954/// `[seq_len, n_kv_heads, head_dim]`. Metal prefill kernels must match.
955pub fn causal_gqa_attention_prefill(
956    q: &[f32],
957    k_cache: &[f32],
958    v_cache: &[f32],
959    n_heads: usize,
960    n_kv_heads: usize,
961    head_dim: usize,
962    seq_len: usize,
963) -> Vec<f32> {
964    assert_eq!(q.len(), seq_len * n_heads * head_dim);
965    assert_eq!(k_cache.len(), seq_len * n_kv_heads * head_dim);
966    assert_eq!(v_cache.len(), seq_len * n_kv_heads * head_dim);
967
968    let q_stride = n_heads * head_dim;
969    let kv_stride = n_kv_heads * head_dim;
970    let mut out = vec![0f32; seq_len * q_stride];
971    for t in 0..seq_len {
972        let q_t = &q[t * q_stride..(t + 1) * q_stride];
973        let k_prefix = &k_cache[..(t + 1) * kv_stride];
974        let v_prefix = &v_cache[..(t + 1) * kv_stride];
975        let attn = causal_gqa_attention(
976            q_t,
977            k_prefix,
978            v_prefix,
979            n_heads,
980            n_kv_heads,
981            head_dim,
982            t + 1,
983        );
984        out[t * q_stride..(t + 1) * q_stride].copy_from_slice(&attn);
985    }
986    out
987}
988
989/// Prefill attention parallelized over `(query, head)` slots. Same math as
990/// calling [`causal_gqa_attention_softcap`] per query; used by the decoder
991/// CPU pp path so Rayon owns the full `[n_q × n_heads]` grid instead of
992/// only the query axis (better for large-head models like Phi-4).
993#[allow(clippy::too_many_arguments)]
994pub fn causal_gqa_attention_prefill_shared_kv(
995    q: &[f32],
996    k_cache: &[f32],
997    v_cache: &[f32],
998    n_heads: usize,
999    n_kv_heads: usize,
1000    head_dim: usize,
1001    n_q: usize,
1002    kv_prefix: usize,
1003    attn_softcap: Option<f32>,
1004) -> Vec<f32> {
1005    causal_gqa_attention_prefill_shared_kv_windowed(
1006        q,
1007        k_cache,
1008        v_cache,
1009        n_heads,
1010        n_kv_heads,
1011        head_dim,
1012        n_q,
1013        kv_prefix,
1014        attn_softcap,
1015        None,
1016    )
1017}
1018
1019/// [`causal_gqa_attention_prefill_shared_kv`] with an optional sliding
1020/// window, so SWA models (Gemma-2/3, Mistral, Qwen2-MoE) get the same
1021/// blocked kernel instead of the per-query
1022/// [`causal_gqa_attention_windowed_softcap`] fallback.
1023///
1024/// `window = Some(w)` restricts the query at absolute position `p`
1025/// (`p = kv_prefix + b`) to keys `p + 1 - w ..= p`, matching
1026/// [`causal_gqa_attention_windowed_softcap`]'s
1027/// `window_start = seq_len.saturating_sub(window)` exactly — the
1028/// per-query function is called with `seq_len = p + 1`. `None` is
1029/// full causal.
1030#[allow(clippy::too_many_arguments)]
1031pub fn causal_gqa_attention_prefill_shared_kv_windowed(
1032    q: &[f32],
1033    k_cache: &[f32],
1034    v_cache: &[f32],
1035    n_heads: usize,
1036    n_kv_heads: usize,
1037    head_dim: usize,
1038    n_q: usize,
1039    kv_prefix: usize,
1040    attn_softcap: Option<f32>,
1041    window: Option<usize>,
1042) -> Vec<f32> {
1043    use rayon::prelude::*;
1044    let q_stride = n_heads * head_dim;
1045    let kv_stride = n_kv_heads * head_dim;
1046    assert_eq!(q.len(), n_q * q_stride);
1047    let kv_len = kv_prefix + n_q;
1048    assert!(k_cache.len() >= kv_len * kv_stride);
1049    assert!(v_cache.len() >= kv_len * kv_stride);
1050
1051    let group_size = n_heads / n_kv_heads.max(1);
1052    let scale = 1.0 / (head_dim as f32).sqrt();
1053    let mut out = vec![0f32; n_q * q_stride];
1054
1055    // Blocked three-pass attention in llama.cpp's CPU shape: `KQ` as one
1056    // real `ggml_mul_mat`, one vectorized softmax over each score row,
1057    // then `KQV` as a second `ggml_mul_mat`. Tasks own a block of
1058    // queries for one head, so the K/V rows they stream stay hot across
1059    // the block; the raw pointer only bridges Send/Sync -- tasks write
1060    // disjoint `(query, head)` slices.
1061    struct OutPtr(*mut f32);
1062    unsafe impl Send for OutPtr {}
1063    unsafe impl Sync for OutPtr {}
1064    impl OutPtr {
1065        /// Safety: no two concurrent callers may overlap `[off, off+len)`.
1066        #[inline]
1067        unsafe fn write(&self, off: usize, src: &[f32]) {
1068            std::ptr::copy_nonoverlapping(src.as_ptr(), self.0.add(off), src.len());
1069        }
1070    }
1071
1072    /// Per-worker scratch, reused across every task a Rayon worker
1073    /// runs: a packed Q tile, the `[Q_BLOCK, span]` score tile and the
1074    /// `[Q_BLOCK, head_dim]` output accumulator. Allocating these per
1075    /// task cost a malloc/free pair and a memset per `(query-block,
1076    /// head)`, of which a `pp512` layer has `n_q/8 * n_heads`.
1077    #[derive(Default)]
1078    struct Scratch {
1079        q_tile: Vec<f32>,
1080        scores: Vec<f32>,
1081        acc: Vec<f32>,
1082    }
1083
1084    const Q_BLOCK: usize = 8;
1085    let n_blocks = n_q.div_ceil(Q_BLOCK);
1086    let out_w = OutPtr(out.as_mut_ptr());
1087    let softcap = attn_softcap.filter(|&c| c > 0.0);
1088
1089    (0..n_blocks * n_heads)
1090        .into_par_iter()
1091        .with_min_len(1)
1092        .for_each_init(Scratch::default, |scratch, task| {
1093            let Scratch {
1094                q_tile,
1095                scores,
1096                acc,
1097            } = scratch;
1098            let blk = task / n_heads;
1099            let h = task % n_heads;
1100            let kv_h = h / group_size.max(1);
1101            let b_start = blk * Q_BLOCK;
1102            let b_end = (b_start + Q_BLOCK).min(n_q);
1103            let n_b = b_end - b_start;
1104
1105            // The block's visible KV span. `t_hi` is the widest causal
1106            // length in the block; `t_lo` is the earliest position its
1107            // first query can still see under the window.
1108            let t_hi = kv_prefix + b_end;
1109            let t_lo = match window {
1110                Some(w) => (kv_prefix + b_start + 1).saturating_sub(w),
1111                None => 0,
1112            };
1113            let span = t_hi - t_lo;
1114            let kv_off = t_lo * kv_stride + kv_h * head_dim;
1115
1116            // Pack the block's Q rows for this head contiguously. The
1117            // GEMM then reads them with `lda = head_dim` instead of
1118            // `n_heads * head_dim`, which for a 32-head model is the
1119            // difference between one tile living in L1 and touching 32
1120            // cache lines per step.
1121            q_tile.clear();
1122            for b in b_start..b_end {
1123                q_tile.extend_from_slice(&q[b * q_stride + h * head_dim..][..head_dim]);
1124            }
1125
1126            // Pass 1: `scores[n_b, span] = scale * Q_tile * Kᵀ` as a
1127            // register-tiled GEMM, computed over the **full** rectangle
1128            // with no mask. Pass 2 zeroes every entry outside a query's
1129            // visible range before pass 3 reads it, so the masked-out
1130            // corners are dead values, never wrong ones: at most
1131            // `Q_BLOCK-1` extra columns per row (0.7% of a 512-wide
1132            // span) bought in exchange for a dense inner loop.
1133            scores.resize(n_b * span, 0.0);
1134            qk_tile(
1135                q_tile, n_b, head_dim, k_cache, kv_off, kv_stride, span, scale, scores,
1136            );
1137
1138            // Pass 2: softcap, then max-subtract softmax, over exactly
1139            // each query's visible range -- and an explicit zero
1140            // everywhere else, which is what turns pass 3 into a dense
1141            // GEMM (llama.cpp reaches the same state by adding a `-INF`
1142            // mask row before `ggml_soft_max_ext`).
1143            let mut norms = [0f32; Q_BLOCK];
1144            for b in b_start..b_end {
1145                let causal_len = kv_prefix + b + 1;
1146                // Same visible range as `causal_gqa_attention_windowed_softcap`
1147                // called with `seq_len = causal_len`.
1148                let t_start = match window {
1149                    Some(w) => causal_len.saturating_sub(w),
1150                    None => 0,
1151                };
1152                let row = &mut scores[(b - b_start) * span..][..span];
1153                let lo = t_start - t_lo;
1154                let hi = causal_len - t_lo;
1155                row[..lo].fill(0.0);
1156                row[hi..].fill(0.0);
1157                let live = &mut row[lo..hi];
1158                if let Some(sc) = softcap {
1159                    for s in live.iter_mut() {
1160                        *s = sc * (*s / sc).tanh();
1161                    }
1162                }
1163                norms[b - b_start] = softmax_row_exp_sum(live);
1164            }
1165
1166            // Pass 3: `acc[n_b, head_dim] += P * V`, the second GEMM.
1167            // Zero probabilities contribute `fma(v, 0, acc) == acc`
1168            // exactly, so dropping the mask here is bit-identical to
1169            // skipping those positions.
1170            acc.resize(n_b * head_dim, 0.0);
1171            acc.fill(0.0);
1172            pv_tile(scores, n_b, span, v_cache, kv_off, kv_stride, head_dim, acc);
1173
1174            for b in b_start..b_end {
1175                let out_h = &mut acc[(b - b_start) * head_dim..][..head_dim];
1176                let l = norms[b - b_start];
1177                if l > 0.0 {
1178                    scale_inplace(out_h, 1.0 / l);
1179                }
1180                unsafe {
1181                    out_w.write(b * q_stride + h * head_dim, out_h);
1182                }
1183            }
1184        });
1185
1186    out
1187}
1188
1189/// In-place row softmax for the blocked prefill kernel: `x[i]` becomes
1190/// `exp(x[i] - max(x))` and the sum of those exponentials is returned,
1191/// so the caller divides once at the end instead of normalising per
1192/// position.
1193///
1194/// This is the third pass of the blocked form, and on small models it
1195/// was the expensive one. `pass 1` and `pass 3` are register-tiled
1196/// GEMMs; this pass was a **scalar `f32::exp` per (query, KV position)**,
1197/// i.e. one libm `expf` call for every score the GEMM had just produced
1198/// four-at-a-time. At `pp512` a single layer of a 32-head model issues
1199/// `512 × 32 × ~256 ≈ 4.2 M` of them.
1200///
1201/// llama.cpp does not pay that: `ggml_vec_soft_max_f32`
1202/// (`ggml/src/ggml-cpu/vec.cpp`) exponentiates a whole row through
1203/// `ggml_v_expf` (`ggml/src/ggml-cpu/vec.h`), which is ARM's
1204/// optimized-routines `expf` rewritten over a vector register. This is
1205/// that same routine; see [`expf_neon`] for the derivation.
1206///
1207/// **This changes CPU prefill numerics**, and deliberately: the
1208/// polynomial is not libm's `expf` to the last bit, and the vector
1209/// accumulator reassociates the sum. On a near-tie that is enough to
1210/// move a greedy argmax, so a CPU generation is not token-identical to
1211/// what the scalar form produced. It is not *less* accurate -- the
1212/// probabilities land at the same handful of ulps and the normaliser
1213/// lands closer to the truth, which
1214/// `the_vectorised_softmax_is_no_less_accurate_than_the_scalar_one`
1215/// measures against an `f64` reference.
1216///
1217/// The reduction order of the max is irrelevant (max is associative and
1218/// the scores are finite). Both the kernel and the row-at-a-time
1219/// reference it is pinned against call this one function, which is what
1220/// keeps `position_outer_prefill_is_bit_identical_to_the_query_outer_form`
1221/// an equality test rather than a tolerance;
1222/// `vectorised_softmax_row_matches_the_scalar_libm_form` is what checks
1223/// this function against `f32::exp` itself.
1224#[inline]
1225fn softmax_row_exp_sum(x: &mut [f32]) -> f32 {
1226    if x.is_empty() {
1227        // A zero-width visible range (`window == 0`) leaves the caller's
1228        // accumulator at zero and skips the normalisation, which is what
1229        // the scalar form did too: `l` never left `0.0`.
1230        return 0.0;
1231    }
1232    #[cfg(target_arch = "aarch64")]
1233    {
1234        if std::arch::is_aarch64_feature_detected!("neon") {
1235            return unsafe { softmax_row_exp_sum_neon(x) };
1236        }
1237    }
1238    #[cfg(target_arch = "x86_64")]
1239    {
1240        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
1241            return unsafe { softmax_row_exp_sum_avx2(x) };
1242        }
1243    }
1244    softmax_row_exp_sum_scalar(x)
1245}
1246
1247/// Scalar `softmax_row_exp_sum` for hosts with neither NEON nor AVX2 --
1248/// and the shape the SIMD arms are tested against.
1249fn softmax_row_exp_sum_scalar(x: &mut [f32]) -> f32 {
1250    let m = x.iter().fold(f32::NEG_INFINITY, |a, &s| a.max(s));
1251    let mut l = 0f32;
1252    for s in x.iter_mut() {
1253        *s = (*s - m).exp();
1254        l += *s;
1255    }
1256    l
1257}
1258
1259/// `exp(x)` for four lanes at once: ARM optimized-routines' `expf` in
1260/// the shape llama.cpp vendors as `ggml_v_expf`
1261/// (`ggml/src/ggml-cpu/vec.h`, the `__ARM_NEON` arm).
1262///
1263/// `z = fma(x, log2(e), 0x1.8p23)` rounds `x·log2(e)` to an integer `n`
1264/// by the round-to-nearest of the add itself, and leaves that integer in
1265/// the low mantissa bits of `z`, so `bits(z) << 23` is exactly the
1266/// exponent field of `2^n` -- one shift instead of a conversion and a
1267/// scalb. `b = x - n·ln2_hi - n·ln2_lo` is the reduced argument in
1268/// `[-ln2/2, ln2/2]` (split so the product is exact in `f32`), and the
1269/// degree-5 minimax polynomial evaluates `e^b - 1` there. The result is
1270/// `2^n · (1 + j)`, accurate to under an ulp.
1271///
1272/// **The overflow branch of the original is dropped, and the clamp is
1273/// what makes that sound.** llama.cpp keeps a slow path for `|n| > 126`
1274/// because `ggml_v_expf` is a general `expf`. Here every argument is
1275/// `score - row_max`, hence `<= 0`, and `exp` of anything below about
1276/// `-87.3` is already smaller than the smallest normal `f32` -- so
1277/// clamping the input at `-87` changes no representable output (the row
1278/// max itself contributes `exp(0) == 1.0` exactly, so a clamped term is
1279/// at most `1.6e-38` of the sum) while pinning `n` to `[-125.5, 0]`,
1280/// where the fast path is the only path.
1281#[cfg(target_arch = "aarch64")]
1282#[target_feature(enable = "neon")]
1283#[inline]
1284unsafe fn expf_neon(x: std::arch::aarch64::float32x4_t) -> std::arch::aarch64::float32x4_t {
1285    use std::arch::aarch64::*;
1286    let x = vmaxq_f32(x, vdupq_n_f32(EXP_MIN_ARG));
1287    let r = vdupq_n_f32(EXP_SHIFT);
1288    let z = vfmaq_f32(r, x, vdupq_n_f32(EXP_LOG2E));
1289    let n = vsubq_f32(z, r);
1290    // `b = x - n*ln2_hi - n*ln2_lo`; `vfmsq_f32(a, b, c) == a - b*c`.
1291    let b = vfmsq_f32(
1292        vfmsq_f32(x, n, vdupq_n_f32(EXP_LN2_HI)),
1293        n,
1294        vdupq_n_f32(EXP_LN2_LO),
1295    );
1296    // `2^n`, built by dropping `n` into the exponent field. The add
1297    // wraps for negative `n`, which is exactly the intended borrow.
1298    let e = vshlq_n_u32::<23>(vreinterpretq_u32_f32(z));
1299    let k = vreinterpretq_f32_u32(vaddq_u32(e, vreinterpretq_u32_f32(vdupq_n_f32(1.0))));
1300    let u = vmulq_f32(b, b);
1301    let j = vfmaq_f32(
1302        vmulq_f32(vdupq_n_f32(EXP_C0), b),
1303        vfmaq_f32(
1304            vfmaq_f32(vdupq_n_f32(EXP_C1), vdupq_n_f32(EXP_C2), b),
1305            vfmaq_f32(vdupq_n_f32(EXP_C3), vdupq_n_f32(EXP_C4), b),
1306            u,
1307        ),
1308        u,
1309    );
1310    vfmaq_f32(k, j, k)
1311}
1312
1313/// AVX2 sibling of [`expf_neon`]: same constants, same polynomial, same
1314/// clamp, eight lanes (`ggml_v_expf`'s `__AVX2__ && __FMA__` arm).
1315#[cfg(target_arch = "x86_64")]
1316#[target_feature(enable = "avx2,fma")]
1317#[inline]
1318unsafe fn expf_avx2(x: std::arch::x86_64::__m256) -> std::arch::x86_64::__m256 {
1319    use std::arch::x86_64::*;
1320    let x = _mm256_max_ps(x, _mm256_set1_ps(EXP_MIN_ARG));
1321    let r = _mm256_set1_ps(EXP_SHIFT);
1322    let z = _mm256_fmadd_ps(x, _mm256_set1_ps(EXP_LOG2E), r);
1323    let n = _mm256_sub_ps(z, r);
1324    let b = _mm256_fnmadd_ps(
1325        n,
1326        _mm256_set1_ps(EXP_LN2_LO),
1327        _mm256_fnmadd_ps(n, _mm256_set1_ps(EXP_LN2_HI), x),
1328    );
1329    let e = _mm256_slli_epi32::<23>(_mm256_castps_si256(z));
1330    let k = _mm256_castsi256_ps(_mm256_add_epi32(
1331        e,
1332        _mm256_castps_si256(_mm256_set1_ps(1.0)),
1333    ));
1334    let u = _mm256_mul_ps(b, b);
1335    let j = _mm256_fmadd_ps(
1336        _mm256_fmadd_ps(
1337            _mm256_fmadd_ps(_mm256_set1_ps(EXP_C4), b, _mm256_set1_ps(EXP_C3)),
1338            u,
1339            _mm256_fmadd_ps(_mm256_set1_ps(EXP_C2), b, _mm256_set1_ps(EXP_C1)),
1340        ),
1341        u,
1342        _mm256_mul_ps(_mm256_set1_ps(EXP_C0), b),
1343    );
1344    _mm256_fmadd_ps(j, k, k)
1345}
1346
1347// The `ggml_v_expf` constants, shared by both vector arms and used by
1348// neither scalar path -- gated so a host with no SIMD arm at all still
1349// compiles clean under `-D warnings`.
1350#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
1351mod exp_consts {
1352    // Shared with `matmul`, which had a byte-identical copy of these
1353    // nine. Only EXP_MIN_ARG below is ours: a softmax argument is
1354    // always <= 0, so this clamps below only, where `matmul` must also
1355    // select zero above because its argument is an unbounded
1356    // denominator.
1357    pub use crate::vexp::*;
1358
1359    /// Below this the exponential is smaller than `f32::MIN_POSITIVE`, so
1360    /// clamping here costs nothing and keeps `n` inside the fast path.
1361    pub const EXP_MIN_ARG: f32 = -87.0;
1362}
1363#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
1364use exp_consts::*;
1365
1366#[cfg(target_arch = "aarch64")]
1367#[target_feature(enable = "neon")]
1368unsafe fn softmax_row_exp_sum_neon(x: &mut [f32]) -> f32 {
1369    use std::arch::aarch64::*;
1370    let n = x.len();
1371    let p = x.as_mut_ptr();
1372    let nv = n & !3;
1373
1374    let mut mv = vdupq_n_f32(f32::NEG_INFINITY);
1375    let mut i = 0;
1376    while i < nv {
1377        mv = vmaxq_f32(mv, vld1q_f32(p.add(i)));
1378        i += 4;
1379    }
1380    let mut m = if nv == 0 {
1381        f32::NEG_INFINITY
1382    } else {
1383        vmaxvq_f32(mv)
1384    };
1385    for j in nv..n {
1386        m = m.max(*p.add(j));
1387    }
1388
1389    let mvec = vdupq_n_f32(m);
1390    let mut sv = vdupq_n_f32(0.0);
1391    let mut i = 0;
1392    while i < nv {
1393        let e = expf_neon(vsubq_f32(vld1q_f32(p.add(i)), mvec));
1394        vst1q_f32(p.add(i), e);
1395        sv = vaddq_f32(sv, e);
1396        i += 4;
1397    }
1398    let mut l = vaddvq_f32(sv);
1399    if nv < n {
1400        // The tail goes through the same approximation rather than
1401        // `f32::exp`, so a row's values do not change character at the
1402        // width boundary. Padding lanes hold `0.0`; they are exponentiated
1403        // and then simply not read.
1404        let mut buf = [0f32; 4];
1405        for (j, slot) in (nv..n).zip(buf.iter_mut()) {
1406            *slot = *p.add(j) - m;
1407        }
1408        vst1q_f32(buf.as_mut_ptr(), expf_neon(vld1q_f32(buf.as_ptr())));
1409        for (j, &e) in (nv..n).zip(buf.iter()) {
1410            *p.add(j) = e;
1411            l += e;
1412        }
1413    }
1414    l
1415}
1416
1417#[cfg(target_arch = "x86_64")]
1418#[target_feature(enable = "avx2,fma")]
1419unsafe fn softmax_row_exp_sum_avx2(x: &mut [f32]) -> f32 {
1420    use std::arch::x86_64::*;
1421    let n = x.len();
1422    let p = x.as_mut_ptr();
1423    let nv = n & !7;
1424
1425    let mut mv = _mm256_set1_ps(f32::NEG_INFINITY);
1426    let mut i = 0;
1427    while i < nv {
1428        mv = _mm256_max_ps(mv, _mm256_loadu_ps(p.add(i)));
1429        i += 8;
1430    }
1431    let mut m = if nv == 0 {
1432        f32::NEG_INFINITY
1433    } else {
1434        let mut lanes = [0f32; 8];
1435        _mm256_storeu_ps(lanes.as_mut_ptr(), mv);
1436        lanes.iter().fold(f32::NEG_INFINITY, |a, &s| a.max(s))
1437    };
1438    for j in nv..n {
1439        m = m.max(*p.add(j));
1440    }
1441
1442    let mvec = _mm256_set1_ps(m);
1443    let mut sv = _mm256_setzero_ps();
1444    let mut i = 0;
1445    while i < nv {
1446        let e = expf_avx2(_mm256_sub_ps(_mm256_loadu_ps(p.add(i)), mvec));
1447        _mm256_storeu_ps(p.add(i), e);
1448        sv = _mm256_add_ps(sv, e);
1449        i += 8;
1450    }
1451    let mut l = hsum256_ps(sv);
1452    if nv < n {
1453        let mut buf = [0f32; 8];
1454        for (j, slot) in (nv..n).zip(buf.iter_mut()) {
1455            *slot = *p.add(j) - m;
1456        }
1457        _mm256_storeu_ps(buf.as_mut_ptr(), expf_avx2(_mm256_loadu_ps(buf.as_ptr())));
1458        for (j, &e) in (nv..n).zip(buf.iter()) {
1459            *p.add(j) = e;
1460            l += e;
1461        }
1462    }
1463    l
1464}
1465
1466/// `scores[b][t] = scale · Σ_d q_tile[b][d]·k[t][d]` for one query block
1467/// against one head's K rows: the `KQ` matmul that llama.cpp expresses as
1468/// a plain `ggml_mul_mat` and dispatches into tinyBLAS.
1469///
1470/// `q_tile` is packed contiguous `[n_b, head_dim]`; K row `t` lives at
1471/// `k[k_off + t*k_stride ..][..head_dim]`, so the caller's
1472/// `[pos, kv_head, dim]` cache needs no repack.
1473///
1474/// The port is of tinyBLAS's `gemm_bloc_<RM>x<RN>`
1475/// (`ggml/src/ggml-cpu/llamafile/sgemm.cpp`): hold an `RM × RN` register
1476/// tile of vector accumulators, load `RM` A-vectors and `RN` B-vectors per
1477/// step along `k`, and horizontally sum once at the end. What it replaces
1478/// was a `dot_f32` per `(query, KV position)`, i.e. a whole K row re-read
1479/// per query -- two loads for every FMA. The 4×4 NEON tile issues eight
1480/// loads for sixteen FMAs, and each K row is read once per query block
1481/// rather than once per query.
1482///
1483/// The reduction order is deliberately the same as [`dot_f32`]'s on each
1484/// backend (4-wide + `vaddvq` under NEON, 8-wide + the same horizontal sum
1485/// under AVX2, scalar tail after the horizontal sum), so this is
1486/// bit-identical to the row-at-a-time loop rather than merely close.
1487// Kept out of line: one call per `(query-block, head)` costs nothing
1488// against a 512x64x64 tile of FMAs, and it keeps this kernel a named
1489// symbol in a `sample` profile instead of vanishing into the Rayon
1490// closure -- which is how its cost was found in the first place.
1491#[inline(never)]
1492#[allow(clippy::too_many_arguments)]
1493fn qk_tile(
1494    q_tile: &[f32],
1495    n_b: usize,
1496    head_dim: usize,
1497    k: &[f32],
1498    k_off: usize,
1499    k_stride: usize,
1500    span: usize,
1501    scale: f32,
1502    scores: &mut [f32],
1503) {
1504    debug_assert_eq!(q_tile.len(), n_b * head_dim);
1505    debug_assert_eq!(scores.len(), n_b * span);
1506    #[cfg(target_arch = "aarch64")]
1507    {
1508        if std::arch::is_aarch64_feature_detected!("neon") {
1509            unsafe {
1510                qk_tile_neon(
1511                    q_tile, n_b, head_dim, k, k_off, k_stride, span, scale, scores,
1512                )
1513            };
1514            return;
1515        }
1516    }
1517    #[cfg(target_arch = "x86_64")]
1518    {
1519        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
1520            unsafe {
1521                qk_tile_avx2(
1522                    q_tile, n_b, head_dim, k, k_off, k_stride, span, scale, scores,
1523                )
1524            };
1525            return;
1526        }
1527    }
1528    qk_rows(
1529        q_tile,
1530        head_dim,
1531        k,
1532        k_off,
1533        k_stride,
1534        span,
1535        scale,
1536        scores,
1537        0..n_b,
1538        0..span,
1539    );
1540}
1541
1542/// Row-at-a-time `Q·Kᵀ` over a sub-rectangle of the score tile: the
1543/// edges the register tile does not cover, and the whole tile on hosts
1544/// with neither NEON nor AVX2.
1545#[allow(clippy::too_many_arguments)]
1546fn qk_rows(
1547    q_tile: &[f32],
1548    head_dim: usize,
1549    k: &[f32],
1550    k_off: usize,
1551    k_stride: usize,
1552    span: usize,
1553    scale: f32,
1554    scores: &mut [f32],
1555    rows: std::ops::Range<usize>,
1556    cols: std::ops::Range<usize>,
1557) {
1558    for b in rows {
1559        let q_b = &q_tile[b * head_dim..][..head_dim];
1560        for t in cols.clone() {
1561            let k_t = &k[k_off + t * k_stride..][..head_dim];
1562            scores[b * span + t] = dot_f32(q_b, k_t) * scale;
1563        }
1564    }
1565}
1566
1567#[cfg(target_arch = "aarch64")]
1568#[target_feature(enable = "neon")]
1569#[allow(clippy::too_many_arguments)]
1570unsafe fn qk_tile_neon(
1571    q_tile: &[f32],
1572    n_b: usize,
1573    head_dim: usize,
1574    k: &[f32],
1575    k_off: usize,
1576    k_stride: usize,
1577    span: usize,
1578    scale: f32,
1579    scores: &mut [f32],
1580) {
1581    use std::arch::aarch64::*;
1582    let qp = q_tile.as_ptr();
1583    let kp = k.as_ptr().add(k_off);
1584    let sp = scores.as_mut_ptr();
1585    // Rows/columns the 4×4 tile covers, and the 4-wide part of `head_dim`
1586    // -- the same boundary `dot_f32_neon` uses, which is what keeps the
1587    // scalar leftovers bit-identical.
1588    let bt = n_b & !3;
1589    let tt = span & !3;
1590    let dv = head_dim & !3;
1591
1592    // KV position outer, query block inner: the four K rows of a tile are
1593    // loaded once and reused by every query tile, so one pass over this
1594    // head's K slab serves the whole query block.
1595    let mut t0 = 0;
1596    while t0 < tt {
1597        let k0 = kp.add(t0 * k_stride);
1598        let k1 = k0.add(k_stride);
1599        let k2 = k1.add(k_stride);
1600        let k3 = k2.add(k_stride);
1601        let mut b0 = 0;
1602        while b0 < bt {
1603            let a0 = qp.add(b0 * head_dim);
1604            let a1 = a0.add(head_dim);
1605            let a2 = a1.add(head_dim);
1606            let a3 = a2.add(head_dim);
1607            let z = vdupq_n_f32(0.0);
1608            // `cIJ` accumulates query `b0+I` against key `t0+J`.
1609            let (mut c00, mut c01, mut c02, mut c03) = (z, z, z, z);
1610            let (mut c10, mut c11, mut c12, mut c13) = (z, z, z, z);
1611            let (mut c20, mut c21, mut c22, mut c23) = (z, z, z, z);
1612            let (mut c30, mut c31, mut c32, mut c33) = (z, z, z, z);
1613            let mut d = 0;
1614            while d < dv {
1615                let av0 = vld1q_f32(a0.add(d));
1616                let av1 = vld1q_f32(a1.add(d));
1617                let av2 = vld1q_f32(a2.add(d));
1618                let av3 = vld1q_f32(a3.add(d));
1619                let kv0 = vld1q_f32(k0.add(d));
1620                c00 = vfmaq_f32(c00, av0, kv0);
1621                c10 = vfmaq_f32(c10, av1, kv0);
1622                c20 = vfmaq_f32(c20, av2, kv0);
1623                c30 = vfmaq_f32(c30, av3, kv0);
1624                let kv1 = vld1q_f32(k1.add(d));
1625                c01 = vfmaq_f32(c01, av0, kv1);
1626                c11 = vfmaq_f32(c11, av1, kv1);
1627                c21 = vfmaq_f32(c21, av2, kv1);
1628                c31 = vfmaq_f32(c31, av3, kv1);
1629                let kv2 = vld1q_f32(k2.add(d));
1630                c02 = vfmaq_f32(c02, av0, kv2);
1631                c12 = vfmaq_f32(c12, av1, kv2);
1632                c22 = vfmaq_f32(c22, av2, kv2);
1633                c32 = vfmaq_f32(c32, av3, kv2);
1634                let kv3 = vld1q_f32(k3.add(d));
1635                c03 = vfmaq_f32(c03, av0, kv3);
1636                c13 = vfmaq_f32(c13, av1, kv3);
1637                c23 = vfmaq_f32(c23, av2, kv3);
1638                c33 = vfmaq_f32(c33, av3, kv3);
1639                d += 4;
1640            }
1641            let mut r = [
1642                [
1643                    vaddvq_f32(c00),
1644                    vaddvq_f32(c01),
1645                    vaddvq_f32(c02),
1646                    vaddvq_f32(c03),
1647                ],
1648                [
1649                    vaddvq_f32(c10),
1650                    vaddvq_f32(c11),
1651                    vaddvq_f32(c12),
1652                    vaddvq_f32(c13),
1653                ],
1654                [
1655                    vaddvq_f32(c20),
1656                    vaddvq_f32(c21),
1657                    vaddvq_f32(c22),
1658                    vaddvq_f32(c23),
1659                ],
1660                [
1661                    vaddvq_f32(c30),
1662                    vaddvq_f32(c31),
1663                    vaddvq_f32(c32),
1664                    vaddvq_f32(c33),
1665                ],
1666            ];
1667            // Leftover dims after the horizontal sum, exactly where
1668            // `dot_f32_neon` adds them.
1669            let arow = [a0, a1, a2, a3];
1670            let krow = [k0, k1, k2, k3];
1671            for d in dv..head_dim {
1672                for (i, ai) in arow.iter().enumerate() {
1673                    let av = *ai.add(d);
1674                    for (j, kj) in krow.iter().enumerate() {
1675                        r[i][j] += av * *kj.add(d);
1676                    }
1677                }
1678            }
1679            for (i, ri) in r.iter().enumerate() {
1680                for (j, v) in ri.iter().enumerate() {
1681                    *sp.add((b0 + i) * span + t0 + j) = v * scale;
1682                }
1683            }
1684            b0 += 4;
1685        }
1686        t0 += 4;
1687    }
1688    qk_rows(
1689        q_tile,
1690        head_dim,
1691        k,
1692        k_off,
1693        k_stride,
1694        span,
1695        scale,
1696        scores,
1697        0..bt,
1698        tt..span,
1699    );
1700    qk_rows(
1701        q_tile,
1702        head_dim,
1703        k,
1704        k_off,
1705        k_stride,
1706        span,
1707        scale,
1708        scores,
1709        bt..n_b,
1710        0..span,
1711    );
1712}
1713
1714/// AVX2 sibling of [`qk_tile_neon`]. The register file is half as wide
1715/// (16 YMM), so the tile is 4 queries × 2 keys -- 8 accumulators plus 4
1716/// A-vectors and a B-vector -- instead of 4×4.
1717#[cfg(target_arch = "x86_64")]
1718#[target_feature(enable = "avx2,fma")]
1719#[allow(clippy::too_many_arguments)]
1720unsafe fn qk_tile_avx2(
1721    q_tile: &[f32],
1722    n_b: usize,
1723    head_dim: usize,
1724    k: &[f32],
1725    k_off: usize,
1726    k_stride: usize,
1727    span: usize,
1728    scale: f32,
1729    scores: &mut [f32],
1730) {
1731    use std::arch::x86_64::*;
1732    let qp = q_tile.as_ptr();
1733    let kp = k.as_ptr().add(k_off);
1734    let sp = scores.as_mut_ptr();
1735    let bt = n_b & !3;
1736    let tt = span & !1;
1737    let dv = head_dim & !7;
1738
1739    let mut t0 = 0;
1740    while t0 < tt {
1741        let k0 = kp.add(t0 * k_stride);
1742        let k1 = k0.add(k_stride);
1743        let mut b0 = 0;
1744        while b0 < bt {
1745            let a0 = qp.add(b0 * head_dim);
1746            let a1 = a0.add(head_dim);
1747            let a2 = a1.add(head_dim);
1748            let a3 = a2.add(head_dim);
1749            let z = _mm256_setzero_ps();
1750            let (mut c00, mut c01) = (z, z);
1751            let (mut c10, mut c11) = (z, z);
1752            let (mut c20, mut c21) = (z, z);
1753            let (mut c30, mut c31) = (z, z);
1754            let mut d = 0;
1755            while d < dv {
1756                let av0 = _mm256_loadu_ps(a0.add(d));
1757                let av1 = _mm256_loadu_ps(a1.add(d));
1758                let av2 = _mm256_loadu_ps(a2.add(d));
1759                let av3 = _mm256_loadu_ps(a3.add(d));
1760                let kv0 = _mm256_loadu_ps(k0.add(d));
1761                c00 = _mm256_fmadd_ps(av0, kv0, c00);
1762                c10 = _mm256_fmadd_ps(av1, kv0, c10);
1763                c20 = _mm256_fmadd_ps(av2, kv0, c20);
1764                c30 = _mm256_fmadd_ps(av3, kv0, c30);
1765                let kv1 = _mm256_loadu_ps(k1.add(d));
1766                c01 = _mm256_fmadd_ps(av0, kv1, c01);
1767                c11 = _mm256_fmadd_ps(av1, kv1, c11);
1768                c21 = _mm256_fmadd_ps(av2, kv1, c21);
1769                c31 = _mm256_fmadd_ps(av3, kv1, c31);
1770                d += 8;
1771            }
1772            let mut r = [
1773                [hsum256_ps(c00), hsum256_ps(c01)],
1774                [hsum256_ps(c10), hsum256_ps(c11)],
1775                [hsum256_ps(c20), hsum256_ps(c21)],
1776                [hsum256_ps(c30), hsum256_ps(c31)],
1777            ];
1778            let arow = [a0, a1, a2, a3];
1779            let krow = [k0, k1];
1780            for d in dv..head_dim {
1781                for (i, ai) in arow.iter().enumerate() {
1782                    let av = *ai.add(d);
1783                    for (j, kj) in krow.iter().enumerate() {
1784                        r[i][j] += av * *kj.add(d);
1785                    }
1786                }
1787            }
1788            for (i, ri) in r.iter().enumerate() {
1789                for (j, v) in ri.iter().enumerate() {
1790                    *sp.add((b0 + i) * span + t0 + j) = v * scale;
1791                }
1792            }
1793            b0 += 4;
1794        }
1795        t0 += 2;
1796    }
1797    qk_rows(
1798        q_tile,
1799        head_dim,
1800        k,
1801        k_off,
1802        k_stride,
1803        span,
1804        scale,
1805        scores,
1806        0..bt,
1807        tt..span,
1808    );
1809    qk_rows(
1810        q_tile,
1811        head_dim,
1812        k,
1813        k_off,
1814        k_stride,
1815        span,
1816        scale,
1817        scores,
1818        bt..n_b,
1819        0..span,
1820    );
1821}
1822
1823/// `acc[b][d] += Σ_t p[b][t]·v[t][d]` for one query block against one
1824/// head's V rows: the `KQV` matmul, the second of llama.cpp's two
1825/// attention `ggml_mul_mat`s.
1826///
1827/// V row `t` lives at `v[v_off + t*v_stride ..][..head_dim]`. `p` is the
1828/// `[n_b, span]` probability tile pass 2 produced, already zeroed outside
1829/// each query's visible range, so no mask is needed here.
1830///
1831/// Register-tiled the other way round from [`qk_tile`]: the output tile
1832/// (8 queries × 8 dims) lives in the accumulators and `t` is the
1833/// reduction axis, so a V row is loaded once and feeds all eight
1834/// queries. What it replaces was an `axpy` per `(KV position, query)`,
1835/// which re-loaded and re-stored the whole `head_dim`-wide accumulator
1836/// row for every position -- three L1 accesses per FMA against this
1837/// version's ten loads per sixteen vector FMAs.
1838///
1839/// Accumulation order along `t` is unchanged (ascending, one `fma` per
1840/// position), and the vector/scalar boundary matches [`axpy`]'s on each
1841/// backend, so this is bit-identical to the row-at-a-time loop.
1842// Out of line for the same reason as [`qk_tile`].
1843#[inline(never)]
1844#[allow(clippy::too_many_arguments)]
1845fn pv_tile(
1846    p: &[f32],
1847    n_b: usize,
1848    span: usize,
1849    v: &[f32],
1850    v_off: usize,
1851    v_stride: usize,
1852    head_dim: usize,
1853    acc: &mut [f32],
1854) {
1855    debug_assert_eq!(p.len(), n_b * span);
1856    debug_assert_eq!(acc.len(), n_b * head_dim);
1857    #[cfg(target_arch = "aarch64")]
1858    {
1859        if std::arch::is_aarch64_feature_detected!("neon") {
1860            unsafe { pv_tile_neon(p, n_b, span, v, v_off, v_stride, head_dim, acc) };
1861            return;
1862        }
1863    }
1864    #[cfg(target_arch = "x86_64")]
1865    {
1866        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
1867            unsafe { pv_tile_avx2(p, n_b, span, v, v_off, v_stride, head_dim, acc) };
1868            return;
1869        }
1870    }
1871    pv_rows(p, span, v, v_off, v_stride, head_dim, acc, 0..n_b);
1872}
1873
1874/// Row-at-a-time `P·V` for the query rows the register tile does not
1875/// cover, and for hosts with neither NEON nor AVX2. Zero probabilities
1876/// are skipped rather than accumulated -- `acc + v*0` is exactly `acc`
1877/// for finite `v`, so this is a pure work saving on the masked padding.
1878#[allow(clippy::too_many_arguments)]
1879fn pv_rows(
1880    p: &[f32],
1881    span: usize,
1882    v: &[f32],
1883    v_off: usize,
1884    v_stride: usize,
1885    head_dim: usize,
1886    acc: &mut [f32],
1887    rows: std::ops::Range<usize>,
1888) {
1889    for b in rows {
1890        let out_b = &mut acc[b * head_dim..][..head_dim];
1891        for t in 0..span {
1892            let w = p[b * span + t];
1893            if w == 0.0 {
1894                continue;
1895            }
1896            axpy(out_b, &v[v_off + t * v_stride..][..head_dim], w);
1897        }
1898    }
1899}
1900
1901#[cfg(target_arch = "aarch64")]
1902#[target_feature(enable = "neon")]
1903#[allow(clippy::too_many_arguments)]
1904unsafe fn pv_tile_neon(
1905    p: &[f32],
1906    n_b: usize,
1907    span: usize,
1908    v: &[f32],
1909    v_off: usize,
1910    v_stride: usize,
1911    head_dim: usize,
1912    acc: &mut [f32],
1913) {
1914    use std::arch::aarch64::*;
1915    let vp = v.as_ptr().add(v_off);
1916    let pp = p.as_ptr();
1917    let ap = acc.as_mut_ptr();
1918    let bt = n_b & !7;
1919    let dv = head_dim & !7;
1920    // `axpy_neon` vectorizes up to `head_dim & !3` and goes scalar after;
1921    // matching both boundaries is what makes the leftovers bit-identical.
1922    let dv4 = head_dim & !3;
1923
1924    let mut b0 = 0;
1925    while b0 < bt {
1926        let mut d0 = 0;
1927        while d0 < dv {
1928            let mut c0l = vld1q_f32(ap.add(b0 * head_dim + d0));
1929            let mut c0h = vld1q_f32(ap.add(b0 * head_dim + d0 + 4));
1930            let mut c1l = vld1q_f32(ap.add((b0 + 1) * head_dim + d0));
1931            let mut c1h = vld1q_f32(ap.add((b0 + 1) * head_dim + d0 + 4));
1932            let mut c2l = vld1q_f32(ap.add((b0 + 2) * head_dim + d0));
1933            let mut c2h = vld1q_f32(ap.add((b0 + 2) * head_dim + d0 + 4));
1934            let mut c3l = vld1q_f32(ap.add((b0 + 3) * head_dim + d0));
1935            let mut c3h = vld1q_f32(ap.add((b0 + 3) * head_dim + d0 + 4));
1936            let mut c4l = vld1q_f32(ap.add((b0 + 4) * head_dim + d0));
1937            let mut c4h = vld1q_f32(ap.add((b0 + 4) * head_dim + d0 + 4));
1938            let mut c5l = vld1q_f32(ap.add((b0 + 5) * head_dim + d0));
1939            let mut c5h = vld1q_f32(ap.add((b0 + 5) * head_dim + d0 + 4));
1940            let mut c6l = vld1q_f32(ap.add((b0 + 6) * head_dim + d0));
1941            let mut c6h = vld1q_f32(ap.add((b0 + 6) * head_dim + d0 + 4));
1942            let mut c7l = vld1q_f32(ap.add((b0 + 7) * head_dim + d0));
1943            let mut c7h = vld1q_f32(ap.add((b0 + 7) * head_dim + d0 + 4));
1944            for t in 0..span {
1945                let vr = vp.add(t * v_stride + d0);
1946                let v0 = vld1q_f32(vr);
1947                let v1 = vld1q_f32(vr.add(4));
1948                let s0 = vdupq_n_f32(*pp.add(b0 * span + t));
1949                c0l = vfmaq_f32(c0l, v0, s0);
1950                c0h = vfmaq_f32(c0h, v1, s0);
1951                let s1 = vdupq_n_f32(*pp.add((b0 + 1) * span + t));
1952                c1l = vfmaq_f32(c1l, v0, s1);
1953                c1h = vfmaq_f32(c1h, v1, s1);
1954                let s2 = vdupq_n_f32(*pp.add((b0 + 2) * span + t));
1955                c2l = vfmaq_f32(c2l, v0, s2);
1956                c2h = vfmaq_f32(c2h, v1, s2);
1957                let s3 = vdupq_n_f32(*pp.add((b0 + 3) * span + t));
1958                c3l = vfmaq_f32(c3l, v0, s3);
1959                c3h = vfmaq_f32(c3h, v1, s3);
1960                let s4 = vdupq_n_f32(*pp.add((b0 + 4) * span + t));
1961                c4l = vfmaq_f32(c4l, v0, s4);
1962                c4h = vfmaq_f32(c4h, v1, s4);
1963                let s5 = vdupq_n_f32(*pp.add((b0 + 5) * span + t));
1964                c5l = vfmaq_f32(c5l, v0, s5);
1965                c5h = vfmaq_f32(c5h, v1, s5);
1966                let s6 = vdupq_n_f32(*pp.add((b0 + 6) * span + t));
1967                c6l = vfmaq_f32(c6l, v0, s6);
1968                c6h = vfmaq_f32(c6h, v1, s6);
1969                let s7 = vdupq_n_f32(*pp.add((b0 + 7) * span + t));
1970                c7l = vfmaq_f32(c7l, v0, s7);
1971                c7h = vfmaq_f32(c7h, v1, s7);
1972            }
1973            vst1q_f32(ap.add(b0 * head_dim + d0), c0l);
1974            vst1q_f32(ap.add(b0 * head_dim + d0 + 4), c0h);
1975            vst1q_f32(ap.add((b0 + 1) * head_dim + d0), c1l);
1976            vst1q_f32(ap.add((b0 + 1) * head_dim + d0 + 4), c1h);
1977            vst1q_f32(ap.add((b0 + 2) * head_dim + d0), c2l);
1978            vst1q_f32(ap.add((b0 + 2) * head_dim + d0 + 4), c2h);
1979            vst1q_f32(ap.add((b0 + 3) * head_dim + d0), c3l);
1980            vst1q_f32(ap.add((b0 + 3) * head_dim + d0 + 4), c3h);
1981            vst1q_f32(ap.add((b0 + 4) * head_dim + d0), c4l);
1982            vst1q_f32(ap.add((b0 + 4) * head_dim + d0 + 4), c4h);
1983            vst1q_f32(ap.add((b0 + 5) * head_dim + d0), c5l);
1984            vst1q_f32(ap.add((b0 + 5) * head_dim + d0 + 4), c5h);
1985            vst1q_f32(ap.add((b0 + 6) * head_dim + d0), c6l);
1986            vst1q_f32(ap.add((b0 + 6) * head_dim + d0 + 4), c6h);
1987            vst1q_f32(ap.add((b0 + 7) * head_dim + d0), c7l);
1988            vst1q_f32(ap.add((b0 + 7) * head_dim + d0 + 4), c7h);
1989            d0 += 8;
1990        }
1991        // Leftover dims, still `t`-ascending per `(query, dim)`: fused
1992        // below `head_dim & !3` and plain below `head_dim`, which is
1993        // where `axpy_neon`'s own vector/scalar split falls.
1994        if dv < head_dim {
1995            for t in 0..span {
1996                for i in 0..8 {
1997                    let w = *pp.add((b0 + i) * span + t);
1998                    if w == 0.0 {
1999                        continue;
2000                    }
2001                    let row = ap.add((b0 + i) * head_dim);
2002                    for d in dv..dv4 {
2003                        *row.add(d) = f32::mul_add(w, *vp.add(t * v_stride + d), *row.add(d));
2004                    }
2005                    for d in dv4..head_dim {
2006                        *row.add(d) += w * *vp.add(t * v_stride + d);
2007                    }
2008                }
2009            }
2010        }
2011        b0 += 8;
2012    }
2013    pv_rows(p, span, v, v_off, v_stride, head_dim, acc, bt..n_b);
2014}
2015
2016/// AVX2 sibling of [`pv_tile_neon`]: same 8-query × 8-dim output tile,
2017/// but one YMM accumulator per query instead of two NEON quads.
2018#[cfg(target_arch = "x86_64")]
2019#[target_feature(enable = "avx2,fma")]
2020#[allow(clippy::too_many_arguments)]
2021unsafe fn pv_tile_avx2(
2022    p: &[f32],
2023    n_b: usize,
2024    span: usize,
2025    v: &[f32],
2026    v_off: usize,
2027    v_stride: usize,
2028    head_dim: usize,
2029    acc: &mut [f32],
2030) {
2031    use std::arch::x86_64::*;
2032    let vp = v.as_ptr().add(v_off);
2033    let pp = p.as_ptr();
2034    let ap = acc.as_mut_ptr();
2035    let bt = n_b & !7;
2036    // `axpy_avx2` vectorizes up to `head_dim & !7`, so its scalar tail
2037    // and this kernel's are the same elements.
2038    let dv = head_dim & !7;
2039
2040    let mut b0 = 0;
2041    while b0 < bt {
2042        let mut d0 = 0;
2043        while d0 < dv {
2044            let mut c0 = _mm256_loadu_ps(ap.add(b0 * head_dim + d0));
2045            let mut c1 = _mm256_loadu_ps(ap.add((b0 + 1) * head_dim + d0));
2046            let mut c2 = _mm256_loadu_ps(ap.add((b0 + 2) * head_dim + d0));
2047            let mut c3 = _mm256_loadu_ps(ap.add((b0 + 3) * head_dim + d0));
2048            let mut c4 = _mm256_loadu_ps(ap.add((b0 + 4) * head_dim + d0));
2049            let mut c5 = _mm256_loadu_ps(ap.add((b0 + 5) * head_dim + d0));
2050            let mut c6 = _mm256_loadu_ps(ap.add((b0 + 6) * head_dim + d0));
2051            let mut c7 = _mm256_loadu_ps(ap.add((b0 + 7) * head_dim + d0));
2052            for t in 0..span {
2053                let vv = _mm256_loadu_ps(vp.add(t * v_stride + d0));
2054                c0 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add(b0 * span + t)), c0);
2055                c1 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 1) * span + t)), c1);
2056                c2 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 2) * span + t)), c2);
2057                c3 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 3) * span + t)), c3);
2058                c4 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 4) * span + t)), c4);
2059                c5 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 5) * span + t)), c5);
2060                c6 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 6) * span + t)), c6);
2061                c7 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 7) * span + t)), c7);
2062            }
2063            _mm256_storeu_ps(ap.add(b0 * head_dim + d0), c0);
2064            _mm256_storeu_ps(ap.add((b0 + 1) * head_dim + d0), c1);
2065            _mm256_storeu_ps(ap.add((b0 + 2) * head_dim + d0), c2);
2066            _mm256_storeu_ps(ap.add((b0 + 3) * head_dim + d0), c3);
2067            _mm256_storeu_ps(ap.add((b0 + 4) * head_dim + d0), c4);
2068            _mm256_storeu_ps(ap.add((b0 + 5) * head_dim + d0), c5);
2069            _mm256_storeu_ps(ap.add((b0 + 6) * head_dim + d0), c6);
2070            _mm256_storeu_ps(ap.add((b0 + 7) * head_dim + d0), c7);
2071            d0 += 8;
2072        }
2073        if dv < head_dim {
2074            for t in 0..span {
2075                for i in 0..8 {
2076                    let w = *pp.add((b0 + i) * span + t);
2077                    if w == 0.0 {
2078                        continue;
2079                    }
2080                    let row = ap.add((b0 + i) * head_dim);
2081                    for d in dv..head_dim {
2082                        *row.add(d) += w * *vp.add(t * v_stride + d);
2083                    }
2084                }
2085            }
2086        }
2087        b0 += 8;
2088    }
2089    pv_rows(p, span, v, v_off, v_stride, head_dim, acc, bt..n_b);
2090}
2091
2092/// Same math as `causal_gqa_attention`, but K/V positions are read
2093/// through a `PagedKvStore` block table instead of one contiguous
2094/// slice: position `t` lives in block `block_table[t / block_size]`
2095/// at offset `t % block_size`, so blocks need not be physically
2096/// adjacent or in order. Must match `causal_gqa_attention` given the
2097/// same logical K/V contents (float noise only) — the block table is a
2098/// storage-layout detail, not a math change.
2099pub fn causal_gqa_attention_paged(
2100    q: &[f32],
2101    store: &PagedKvStore,
2102    block_table: &[usize],
2103    n_heads: usize,
2104    n_kv_heads: usize,
2105    head_dim: usize,
2106    seq_len: usize,
2107) -> Vec<f32> {
2108    assert_eq!(q.len(), n_heads * head_dim);
2109    let block_size = store.block_size();
2110    assert!(
2111        block_table.len() * block_size >= seq_len,
2112        "block table too short for seq_len"
2113    );
2114
2115    let group_size = n_heads / n_kv_heads.max(1);
2116    let scale = 1.0 / (head_dim as f32).sqrt();
2117    let mut out = vec![0f32; n_heads * head_dim];
2118
2119    for h in 0..n_heads {
2120        let kv_h = h / group_size.max(1);
2121        let q_h = &q[h * head_dim..(h + 1) * head_dim];
2122        let out_h = &mut out[h * head_dim..(h + 1) * head_dim];
2123        online_attn_accumulate(q_h, scale, head_dim, out_h, None, None, |visit| {
2124            for t in 0..seq_len {
2125                let block_id = block_table[t / block_size];
2126                let offset = t % block_size;
2127                let k_row = store.k_row(block_id, offset);
2128                let v_row = store.v_row(block_id, offset);
2129                let k_t = &k_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2130                let v_t = &v_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2131                visit(k_t, v_t);
2132            }
2133        });
2134    }
2135
2136    out
2137}
2138
2139/// [`causal_gqa_attention_paged`] with per-head attention sinks and an
2140/// optional sliding window: the paged twin of
2141/// [`causal_gqa_attention_sinks`].
2142///
2143/// # Why this had to exist before the paged path could serve anything
2144///
2145/// `causal_gqa_attention_paged` had neither term, and
2146/// `Decoder::forward_token_paged` therefore refused gpt-oss with an
2147/// assert rather than answer it differently from the contiguous path.
2148/// That assert was the right call and a dead end: a sliding-window or
2149/// sink-carrying model could never move onto paged KV, and paged KV is
2150/// what a radix prefix cache hands back page indices for. So this is a
2151/// correctness item before it is a caching one.
2152///
2153/// # Bit-identity is by construction, not by tolerance
2154///
2155/// Both this and the contiguous kernel funnel the same `(k, v)` rows,
2156/// in the same order, through the same [`online_attn_accumulate`] with
2157/// the same scale and the same sink. Nothing is re-associated and no
2158/// sum is reordered, so the results are bit-identical rather than
2159/// close -- which is the only useful bar here, since the whole point is
2160/// that moving a model onto paged KV must not change its distribution.
2161/// The tests assert exact equality.
2162///
2163/// `sinks` is `None` for a model that ships none, which is the ordinary
2164/// case; `window` is `Some(w)` for a sliding-window layer and `None`
2165/// for full causal. `attn_softcap` is carried too, so this one entry
2166/// point can mirror every arm of the contiguous dispatch: a softcapped
2167/// model moved onto paged KV without it would differ silently, which is
2168/// the same class of bug this function exists to close.
2169#[allow(clippy::too_many_arguments)]
2170pub fn causal_gqa_attention_paged_sinks(
2171    q: &[f32],
2172    store: &PagedKvStore,
2173    block_table: &[usize],
2174    n_heads: usize,
2175    n_kv_heads: usize,
2176    head_dim: usize,
2177    seq_len: usize,
2178    window: Option<usize>,
2179    sinks: Option<&[f32]>,
2180    attn_softcap: Option<f32>,
2181) -> Vec<f32> {
2182    assert_eq!(q.len(), n_heads * head_dim);
2183    let block_size = store.block_size();
2184    assert!(
2185        block_table.len() * block_size >= seq_len,
2186        "block table too short for seq_len"
2187    );
2188    if let Some(sinks) = sinks {
2189        assert_eq!(
2190            sinks.len(),
2191            n_heads,
2192            "attention sinks are per query head (llama.cpp `attn_sinks` is {{n_head}})"
2193        );
2194    }
2195
2196    let group_size = n_heads / n_kv_heads.max(1);
2197    let scale = 1.0 / (head_dim as f32).sqrt();
2198    let mut out = vec![0f32; n_heads * head_dim];
2199    // The query is the last cached position; a windowed layer sees only
2200    // the most recent `window` positions including its own. Identical
2201    // to the contiguous kernel's `start`, deliberately: a different
2202    // rounding here would silently shift which token a window drops.
2203    let start = match window {
2204        Some(w) => {
2205            assert!(w > 0, "window must be positive");
2206            seq_len.saturating_sub(w)
2207        }
2208        None => 0,
2209    };
2210
2211    for h in 0..n_heads {
2212        let kv_h = h / group_size.max(1);
2213        let q_h = &q[h * head_dim..(h + 1) * head_dim];
2214        let sink = sinks.map(|s| s[h]);
2215        let out_h = &mut out[h * head_dim..(h + 1) * head_dim];
2216        online_attn_accumulate(q_h, scale, head_dim, out_h, attn_softcap, sink, |visit| {
2217            for t in start..seq_len {
2218                let block_id = block_table[t / block_size];
2219                let offset = t % block_size;
2220                let k_row = store.k_row(block_id, offset);
2221                let v_row = store.v_row(block_id, offset);
2222                let k_t = &k_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2223                let v_t = &v_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2224                visit(k_t, v_t);
2225            }
2226        });
2227    }
2228
2229    out
2230}
2231
2232/// Single-token causal attention for DeepSeek/Kimi-style Multi-head
2233/// Latent Attention (MLA): every query head has its own key/value (no
2234/// GQA-style grouping -- verified directly against Kimi K3's real
2235/// `KimiMLAAttention.forward`, where `kv_b_proj` expands to the full
2236/// `num_heads` count and the `num_key_value_heads`/`num_key_value_groups`
2237/// fields computed in `__init__` go unused), but the key/query head
2238/// dimension (`qk_head_dim` = `qk_nope_head_dim + qk_rope_head_dim`) can
2239/// differ from the value head dimension (`v_head_dim`) -- unlike
2240/// `causal_gqa_attention`, which assumes one shared `head_dim` for both.
2241///
2242/// `q` is [n_heads, qk_head_dim]; `k_cache` is [seq_len, n_heads,
2243/// qk_head_dim]; `v_cache` is [seq_len, n_heads, v_head_dim]. Returns
2244/// [n_heads, v_head_dim].
2245pub fn causal_mla_attention(
2246    q: &[f32],
2247    k_cache: &[f32],
2248    v_cache: &[f32],
2249    n_heads: usize,
2250    qk_head_dim: usize,
2251    v_head_dim: usize,
2252    seq_len: usize,
2253) -> Vec<f32> {
2254    mla_attention_inner(
2255        q,
2256        k_cache,
2257        v_cache,
2258        n_heads,
2259        qk_head_dim,
2260        v_head_dim,
2261        seq_len,
2262        None,
2263        None,
2264    )
2265}
2266
2267/// The one MLA attention body, shared by the dense, sparse and
2268/// sink-carrying entry points.
2269///
2270/// `visible` restricts which key positions participate (`None` is every
2271/// position through `seq_len`); `sinks` is one learned logit per query
2272/// head. Sharing the body is deliberate rather than tidy: the four
2273/// public forms differ only in those two options, and a second copy of
2274/// the softmax is how one of them quietly stops matching the others.
2275///
2276/// A sink joins the softmax denominator with a **zero** value vector,
2277/// so it takes probability mass away from the real keys without
2278/// contributing to the output -- the same semantics as
2279/// [`causal_gqa_attention_sinks`], and for the same reason: it lets a
2280/// head decline to attend to anything rather than being forced to
2281/// spread a full unit of weight over keys it does not want. The sink
2282/// logit is **not** scaled by `1/sqrt(qk_head_dim)`; it is a learned
2283/// logit already in score space.
2284///
2285/// A head whose sink dominates gets an output near zero, which is the
2286/// intended behaviour and not a bug to guard against -- clamping it
2287/// would remove the only thing the sink is for.
2288#[allow(clippy::too_many_arguments)]
2289fn mla_attention_inner(
2290    q: &[f32],
2291    k_cache: &[f32],
2292    v_cache: &[f32],
2293    n_heads: usize,
2294    qk_head_dim: usize,
2295    v_head_dim: usize,
2296    seq_len: usize,
2297    visible: Option<&[usize]>,
2298    sinks: Option<&[f32]>,
2299) -> Vec<f32> {
2300    assert_eq!(q.len(), n_heads * qk_head_dim);
2301    assert_eq!(k_cache.len(), seq_len * n_heads * qk_head_dim);
2302    assert_eq!(v_cache.len(), seq_len * n_heads * v_head_dim);
2303    if let Some(visible) = visible {
2304        assert!(
2305            visible.iter().all(|&t| t < seq_len),
2306            "visible positions must be within seq_len"
2307        );
2308    }
2309    if let Some(sinks) = sinks {
2310        assert_eq!(
2311            sinks.len(),
2312            n_heads,
2313            "one sink logit per query head, or none at all"
2314        );
2315    }
2316
2317    // Indexed rather than materialized: `None` means every position
2318    // through `seq_len`, and building that list would allocate one
2319    // `usize` per cached token on every decode step of every layer --
2320    // paid on the dense path, which is the common one.
2321    let n_positions = visible.map_or(seq_len, |v| v.len());
2322    let position_at = |i: usize| visible.map_or(i, |v| v[i]);
2323
2324    let scale = 1.0 / (qk_head_dim as f32).sqrt();
2325    let mut out = vec![0f32; n_heads * v_head_dim];
2326
2327    for h in 0..n_heads {
2328        let q_h = &q[h * qk_head_dim..(h + 1) * qk_head_dim];
2329
2330        let mut scores = vec![0f32; n_positions];
2331        for (i, score) in scores.iter_mut().enumerate() {
2332            let t = position_at(i);
2333            let k_t =
2334                &k_cache[(t * n_heads + h) * qk_head_dim..(t * n_heads + h + 1) * qk_head_dim];
2335            let mut dot = 0f32;
2336            for d in 0..qk_head_dim {
2337                dot += q_h[d] * k_t[d];
2338            }
2339            *score = dot * scale;
2340        }
2341
2342        let sink = sinks.map(|s| s[h]);
2343        let mut max = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
2344        if let Some(s) = sink {
2345            max = max.max(s);
2346        }
2347        let mut sum = 0f32;
2348        for s in scores.iter_mut() {
2349            *s = (*s - max).exp();
2350            sum += *s;
2351        }
2352        // The sink's mass lands in the denominator only: it has no value
2353        // vector, which is exactly how it removes weight from the real
2354        // keys instead of redistributing it among them.
2355        if let Some(s) = sink {
2356            sum += (s - max).exp();
2357        }
2358        if sum > 0.0 {
2359            for s in scores.iter_mut() {
2360                *s /= sum;
2361            }
2362        }
2363
2364        let out_h = &mut out[h * v_head_dim..(h + 1) * v_head_dim];
2365        for (i, &w) in scores.iter().enumerate() {
2366            let t = position_at(i);
2367            let v_t = &v_cache[(t * n_heads + h) * v_head_dim..(t * n_heads + h + 1) * v_head_dim];
2368            for d in 0..v_head_dim {
2369                out_h[d] += w * v_t[d];
2370            }
2371        }
2372    }
2373
2374    out
2375}
2376
2377/// [`causal_mla_attention`] with DeepSeek V4's per-head attention sinks.
2378///
2379/// `sinks` is one learned logit per query head. See
2380/// [`mla_attention_inner`] for what a sink does and why it is not
2381/// scaled.
2382#[allow(clippy::too_many_arguments)]
2383pub fn causal_mla_attention_sinks(
2384    q: &[f32],
2385    k_cache: &[f32],
2386    v_cache: &[f32],
2387    n_heads: usize,
2388    qk_head_dim: usize,
2389    v_head_dim: usize,
2390    seq_len: usize,
2391    sinks: Option<&[f32]>,
2392) -> Vec<f32> {
2393    mla_attention_inner(
2394        q,
2395        k_cache,
2396        v_cache,
2397        n_heads,
2398        qk_head_dim,
2399        v_head_dim,
2400        seq_len,
2401        None,
2402        sinks,
2403    )
2404}
2405
2406/// [`causal_mla_attention_sparse`] with per-head attention sinks.
2407///
2408/// The sink matters more here than on the dense path: a sparse query
2409/// sees only the positions the indexer selected, and without a sink its
2410/// softmax is forced to spend a full unit of weight on them however
2411/// poorly they match.
2412#[allow(clippy::too_many_arguments)]
2413pub fn causal_mla_attention_sparse_sinks(
2414    q: &[f32],
2415    k_cache: &[f32],
2416    v_cache: &[f32],
2417    n_heads: usize,
2418    qk_head_dim: usize,
2419    v_head_dim: usize,
2420    seq_len: usize,
2421    visible: &[usize],
2422    sinks: Option<&[f32]>,
2423) -> Vec<f32> {
2424    mla_attention_inner(
2425        q,
2426        k_cache,
2427        v_cache,
2428        n_heads,
2429        qk_head_dim,
2430        v_head_dim,
2431        seq_len,
2432        Some(visible),
2433        sinks,
2434    )
2435}
2436
2437/// The DeepSeek-V3.2 / GLM-5.2 "lightning indexer" (arXiv 2512.02556;
2438/// real, merged, tested reference implementations in llama.cpp PR
2439/// #23346 and PR #25407): scores every causally-visible key position
2440/// against the query using a cheap multi-head dot-product indexer, then
2441/// keeps only the `top_k` highest-scoring positions.
2442///
2443/// `indexer_q` is `[n_index_heads][index_head_dim]` for this query
2444/// position; `indexer_keys` is `[num_causal_positions][index_head_dim]`
2445/// (one MQA key per causal position, `0..=query_pos`); `indexer_weights`
2446/// is `[n_index_heads]`. Returns the kept key positions, ascending.
2447///
2448/// The real implementation additionally rotates `indexer_q`/`indexer_k`
2449/// through a fixed orthogonal Hadamard matrix before the dot product (to
2450/// spread values evenly for FP8 quantization on real hardware). An
2451/// orthogonal transform applied identically to both operands leaves
2452/// their dot product unchanged in exact arithmetic
2453/// (`(Hq)·(Hk) = q^T H^T H k = q^T k`), so this f32 CPU path omits it —
2454/// the score computed here is exact, not an approximation of the real
2455/// one.
2456pub fn lightning_indexer_topk(
2457    indexer_q: &[Vec<f32>],
2458    indexer_keys: &[Vec<f32>],
2459    indexer_weights: &[f32],
2460    top_k: usize,
2461) -> Vec<usize> {
2462    let n_heads = indexer_q.len();
2463    assert_eq!(indexer_weights.len(), n_heads);
2464    let index_head_dim = indexer_q.first().map_or(0, |q| q.len());
2465    let scale = 1.0 / ((index_head_dim * n_heads) as f32).sqrt();
2466
2467    let mut scored: Vec<(usize, f32)> = indexer_keys
2468        .iter()
2469        .enumerate()
2470        .map(|(j, k)| {
2471            let score: f32 = indexer_q
2472                .iter()
2473                .zip(indexer_weights.iter())
2474                .map(|(q, w)| {
2475                    let dot: f32 = q.iter().zip(k.iter()).map(|(a, b)| a * b).sum();
2476                    dot.max(0.0) * w * scale
2477                })
2478                .sum();
2479            (j, score)
2480        })
2481        .collect();
2482
2483    let keep = top_k.min(scored.len());
2484    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
2485    let mut kept: Vec<usize> = scored.into_iter().take(keep).map(|(j, _)| j).collect();
2486    kept.sort_unstable();
2487    kept
2488}
2489
2490/// Same as [`causal_mla_attention`] for a single query position, but
2491/// attention is restricted to the explicit `visible` key positions
2492/// (ascending, a subset of `0..seq_len`) rather than the full causal
2493/// history — the sparse-attention half of GLM-5.2/DeepSeek-V3.2's DSA,
2494/// applied after [`lightning_indexer_topk`] selects `visible`.
2495#[allow(clippy::too_many_arguments)]
2496pub fn causal_mla_attention_sparse(
2497    q: &[f32],
2498    k_cache: &[f32],
2499    v_cache: &[f32],
2500    n_heads: usize,
2501    qk_head_dim: usize,
2502    v_head_dim: usize,
2503    seq_len: usize,
2504    visible: &[usize],
2505) -> Vec<f32> {
2506    mla_attention_inner(
2507        q,
2508        k_cache,
2509        v_cache,
2510        n_heads,
2511        qk_head_dim,
2512        v_head_dim,
2513        seq_len,
2514        Some(visible),
2515        None,
2516    )
2517}
2518
2519#[cfg(test)]
2520mod tests {
2521
2522    /// A sink takes probability mass away from the real keys without
2523    /// contributing to the output, so the result shrinks toward zero
2524    /// rather than being redistributed. Without a sink the softmax must
2525    /// spend a full unit of weight on the keys it has, however poorly
2526    /// they match; with one, a head can decline.
2527    #[test]
2528    fn an_mla_sink_removes_weight_from_the_real_keys_instead_of_moving_it() {
2529        let (n_heads, qk, vd, seq) = (2, 2, 2, 2);
2530        let q = vec![1.0, 0.0, 0.0, 1.0];
2531        let k = vec![1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
2532        let v = vec![4.0, 8.0, 1.0, 2.0, 4.0, 8.0, 1.0, 2.0];
2533
2534        let plain = super::causal_mla_attention(&q, &k, &v, n_heads, qk, vd, seq);
2535        let none = super::causal_mla_attention_sinks(&q, &k, &v, n_heads, qk, vd, seq, None);
2536        assert_eq!(plain, none, "no sink must be exactly the old path");
2537
2538        // A sink far above every score takes nearly all the mass.
2539        let big = super::causal_mla_attention_sinks(
2540            &q,
2541            &k,
2542            &v,
2543            n_heads,
2544            qk,
2545            vd,
2546            seq,
2547            Some(&[40.0, 40.0]),
2548        );
2549        for (b, p) in big.iter().zip(plain.iter()) {
2550            assert!(b.abs() < 1e-6, "a dominant sink leaves ~0, got {b} vs {p}");
2551        }
2552
2553        // A sink far below every score changes almost nothing.
2554        let tiny = super::causal_mla_attention_sinks(
2555            &q,
2556            &k,
2557            &v,
2558            n_heads,
2559            qk,
2560            vd,
2561            seq,
2562            Some(&[-40.0, -40.0]),
2563        );
2564        for (s, p) in tiny.iter().zip(plain.iter()) {
2565            assert!((s - p).abs() < 1e-5, "negligible sink: {s} vs {p}");
2566        }
2567    }
2568
2569    /// The sink is per HEAD, so one head may decline while another
2570    /// attends normally. A single shared sink would be a different
2571    /// mechanism, and one that cannot express this.
2572    #[test]
2573    fn each_head_gets_its_own_mla_sink() {
2574        let (n_heads, qk, vd, seq) = (2, 1, 1, 1);
2575        let q = vec![1.0, 1.0];
2576        let k = vec![1.0, 1.0];
2577        let v = vec![5.0, 5.0];
2578
2579        let out = super::causal_mla_attention_sinks(
2580            &q,
2581            &k,
2582            &v,
2583            n_heads,
2584            qk,
2585            vd,
2586            seq,
2587            Some(&[40.0, -40.0]),
2588        );
2589        assert!(out[0].abs() < 1e-6, "head 0 declined: {}", out[0]);
2590        assert!(
2591            (out[1] - 5.0).abs() < 1e-5,
2592            "head 1 attended normally: {}",
2593            out[1]
2594        );
2595    }
2596
2597    /// The sink logit is NOT multiplied by the `1/sqrt(qk_head_dim)`
2598    /// score scale -- it is a learned logit already in score space. If
2599    /// it were scaled, the same checkpoint would sink differently at
2600    /// different head widths, which is what this pins down.
2601    #[test]
2602    fn the_mla_sink_logit_is_not_scaled_by_the_head_width() {
2603        // One key whose score is exactly 0 before and after scaling, so
2604        // the only thing the head width can affect is the sink.
2605        let sink = 0.0f32;
2606        let mut outs = Vec::new();
2607        for qk in [1usize, 4, 16] {
2608            let q = vec![0.0; qk];
2609            let k = vec![0.0; qk];
2610            let v = vec![10.0];
2611            outs.push(super::causal_mla_attention_sinks(&q, &k, &v, 1, qk, 1, 1, Some(&[sink]))[0]);
2612        }
2613        // score 0 and sink 0 split the mass evenly, at every width.
2614        for o in &outs {
2615            assert!((o - 5.0).abs() < 1e-5, "expected 5.0, got {o}");
2616        }
2617    }
2618
2619    /// The sparse path takes a sink too, and it matters more there: a
2620    /// query that sees only the indexer's selection would otherwise be
2621    /// forced to spend a full unit of weight on it.
2622    #[test]
2623    fn the_sparse_mla_path_honours_a_sink_over_the_selected_positions() {
2624        let (n_heads, qk, vd, seq) = (1, 1, 1, 3);
2625        let q = vec![1.0];
2626        let k = vec![1.0, 1.0, 1.0];
2627        let v = vec![2.0, 4.0, 6.0];
2628        let visible = [0usize, 2];
2629
2630        let plain = super::causal_mla_attention_sparse(&q, &k, &v, n_heads, qk, vd, seq, &visible);
2631        let none = super::causal_mla_attention_sparse_sinks(
2632            &q, &k, &v, n_heads, qk, vd, seq, &visible, None,
2633        );
2634        assert_eq!(plain, none);
2635        assert!((plain[0] - 4.0).abs() < 1e-5, "mean of 2 and 6");
2636
2637        let sunk = super::causal_mla_attention_sparse_sinks(
2638            &q,
2639            &k,
2640            &v,
2641            n_heads,
2642            qk,
2643            vd,
2644            seq,
2645            &visible,
2646            Some(&[40.0]),
2647        );
2648        assert!(sunk[0].abs() < 1e-6, "a dominant sink leaves ~0");
2649    }
2650    #[test]
2651    fn prefill_shared_kv_matches_per_query_reference() {
2652        // Shapes chosen to cross the query-block boundary (n_q > 2 blocks,
2653        // with a partial last block), with a nonzero decoded prefix and
2654        // grouped KV heads; softcap both off and on. The blocked
2655        // three-pass softmax must agree with the per-query online
2656        // accumulator within float noise.
2657        let n_heads = 6;
2658        let n_kv_heads = 2;
2659        let head_dim = 16;
2660        let n_q = 19;
2661        let kv_prefix = 5;
2662        let kv_len = kv_prefix + n_q;
2663        let q_stride = n_heads * head_dim;
2664        let kv_stride = n_kv_heads * head_dim;
2665
2666        let q: Vec<f32> = (0..n_q * q_stride)
2667            .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2668            .collect();
2669        let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2670            .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2671            .collect();
2672        let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2673            .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2674            .collect();
2675
2676        for softcap in [None, Some(30.0)] {
2677            let got = super::causal_gqa_attention_prefill_shared_kv(
2678                &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, softcap,
2679            );
2680            assert_eq!(got.len(), n_q * q_stride);
2681            for b in 0..n_q {
2682                let causal_len = kv_prefix + b + 1;
2683                let want = super::causal_gqa_attention_softcap(
2684                    &q[b * q_stride..(b + 1) * q_stride],
2685                    &k_cache[..causal_len * kv_stride],
2686                    &v_cache[..causal_len * kv_stride],
2687                    n_heads,
2688                    n_kv_heads,
2689                    head_dim,
2690                    causal_len,
2691                    softcap,
2692                );
2693                for (i, (g, w)) in got[b * q_stride..(b + 1) * q_stride]
2694                    .iter()
2695                    .zip(want.iter())
2696                    .enumerate()
2697                {
2698                    assert!(
2699                        (g - w).abs() < 1e-5,
2700                        "softcap {softcap:?} query {b} slot {i}: blocked {g} vs online {w}"
2701                    );
2702                }
2703            }
2704        }
2705    }
2706
2707    #[test]
2708    fn windowed_prefill_shared_kv_matches_the_per_query_windowed_reference() {
2709        // Same shapes as `prefill_shared_kv_matches_per_query_reference`,
2710        // now against `causal_gqa_attention_windowed_softcap` — the
2711        // per-query path the decoder's SWA arm used to call. Windows are
2712        // chosen to sit below, across and above the causal prefix so the
2713        // `saturating_sub` boundary is exercised on both sides; the last
2714        // one degenerates to full causal and must match the unwindowed
2715        // kernel too.
2716        let n_heads = 6;
2717        let n_kv_heads = 2;
2718        let head_dim = 16;
2719        let n_q = 19;
2720        let kv_prefix = 5;
2721        let kv_len = kv_prefix + n_q;
2722        let q_stride = n_heads * head_dim;
2723        let kv_stride = n_kv_heads * head_dim;
2724
2725        let q: Vec<f32> = (0..n_q * q_stride)
2726            .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2727            .collect();
2728        let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2729            .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2730            .collect();
2731        let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2732            .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2733            .collect();
2734
2735        for window in [1usize, 3, 7, kv_prefix, kv_len, kv_len + 8] {
2736            for softcap in [None, Some(30.0)] {
2737                let got = super::causal_gqa_attention_prefill_shared_kv_windowed(
2738                    &q,
2739                    &k_cache,
2740                    &v_cache,
2741                    n_heads,
2742                    n_kv_heads,
2743                    head_dim,
2744                    n_q,
2745                    kv_prefix,
2746                    softcap,
2747                    Some(window),
2748                );
2749                assert_eq!(got.len(), n_q * q_stride);
2750                for b in 0..n_q {
2751                    let causal_len = kv_prefix + b + 1;
2752                    let want = super::causal_gqa_attention_windowed_softcap(
2753                        &q[b * q_stride..(b + 1) * q_stride],
2754                        &k_cache[..causal_len * kv_stride],
2755                        &v_cache[..causal_len * kv_stride],
2756                        n_heads,
2757                        n_kv_heads,
2758                        head_dim,
2759                        causal_len,
2760                        window,
2761                        softcap,
2762                    );
2763                    for (i, (g, w)) in got[b * q_stride..(b + 1) * q_stride]
2764                        .iter()
2765                        .zip(want.iter())
2766                        .enumerate()
2767                    {
2768                        assert!(
2769                            (g - w).abs() < 1e-5,
2770                            "window {window} softcap {softcap:?} query {b} slot {i}: \
2771                             blocked {g} vs per-query {w}"
2772                        );
2773                    }
2774                }
2775            }
2776        }
2777
2778        // `window >= kv_len` is full causal: identical to `None`.
2779        let windowed = super::causal_gqa_attention_prefill_shared_kv_windowed(
2780            &q,
2781            &k_cache,
2782            &v_cache,
2783            n_heads,
2784            n_kv_heads,
2785            head_dim,
2786            n_q,
2787            kv_prefix,
2788            None,
2789            Some(kv_len + 8),
2790        );
2791        let full = super::causal_gqa_attention_prefill_shared_kv(
2792            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, None,
2793        );
2794        assert_eq!(windowed, full);
2795    }
2796
2797    /// The query-outer form the blocked kernel had before the K/V rows
2798    /// were hoisted to the outer loop: one query at a time, streaming
2799    /// the whole visible K slab and then the whole visible V slab.
2800    /// Built from the same `dot_f32` / `softmax_row_exp_sum` / `axpy` /
2801    /// `scale_inplace` primitives, so the kernel must match it **bit for
2802    /// bit**, not within a tolerance: reordering which rows are loaded
2803    /// when must not reorder any arithmetic. What those primitives
2804    /// compute is checked separately, `dot_f32` against a scalar sum and
2805    /// `softmax_row_exp_sum` against libm's own `expf` in
2806    /// `vectorised_softmax_row_matches_the_scalar_libm_form`.
2807    #[allow(clippy::too_many_arguments)]
2808    fn prefill_query_outer_reference(
2809        q: &[f32],
2810        k_cache: &[f32],
2811        v_cache: &[f32],
2812        n_heads: usize,
2813        n_kv_heads: usize,
2814        head_dim: usize,
2815        n_q: usize,
2816        kv_prefix: usize,
2817        attn_softcap: Option<f32>,
2818        window: Option<usize>,
2819    ) -> Vec<f32> {
2820        let q_stride = n_heads * head_dim;
2821        let group_size = n_heads / n_kv_heads.max(1);
2822        let scale = 1.0 / (head_dim as f32).sqrt();
2823        let softcap = attn_softcap.filter(|&c| c > 0.0);
2824        let mut out = vec![0f32; n_q * q_stride];
2825        let mut acc = vec![0f32; head_dim];
2826        for h in 0..n_heads {
2827            let kv_h = h / group_size.max(1);
2828            for b in 0..n_q {
2829                let causal_len = kv_prefix + b + 1;
2830                let t_start = match window {
2831                    Some(w) => causal_len.saturating_sub(w),
2832                    None => 0,
2833                };
2834                let q_h = &q[b * q_stride + h * head_dim..][..head_dim];
2835                let mut scores = vec![0f32; causal_len - t_start];
2836                for (i, s) in scores.iter_mut().enumerate() {
2837                    let base = ((t_start + i) * n_kv_heads + kv_h) * head_dim;
2838                    let mut v = super::dot_f32(q_h, &k_cache[base..base + head_dim]) * scale;
2839                    if let Some(sc) = softcap {
2840                        v = sc * (v / sc).tanh();
2841                    }
2842                    *s = v;
2843                }
2844                let l = super::softmax_row_exp_sum(&mut scores);
2845                acc.fill(0.0);
2846                for (i, &p) in scores.iter().enumerate() {
2847                    let base = ((t_start + i) * n_kv_heads + kv_h) * head_dim;
2848                    super::axpy(&mut acc, &v_cache[base..base + head_dim], p);
2849                }
2850                if l > 0.0 {
2851                    super::scale_inplace(&mut acc, 1.0 / l);
2852                }
2853                out[b * q_stride + h * head_dim..][..head_dim].copy_from_slice(&acc);
2854            }
2855        }
2856        out
2857    }
2858
2859    #[test]
2860    fn position_outer_prefill_is_bit_identical_to_the_query_outer_form() {
2861        // Shapes cross the query-block boundary with a partial last
2862        // block, a nonzero decoded prefix and grouped KV heads. Windows
2863        // are chosen so the visible span is narrower than, equal to and
2864        // wider than the block, plus the unwindowed case.
2865        let n_heads = 6;
2866        let n_kv_heads = 2;
2867        let head_dim = 16;
2868        let n_q = 19;
2869        let kv_prefix = 5;
2870        let kv_len = kv_prefix + n_q;
2871        let q_stride = n_heads * head_dim;
2872        let kv_stride = n_kv_heads * head_dim;
2873
2874        let q: Vec<f32> = (0..n_q * q_stride)
2875            .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2876            .collect();
2877        let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2878            .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2879            .collect();
2880        let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2881            .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2882            .collect();
2883
2884        for window in [None, Some(1), Some(3), Some(8), Some(9), Some(kv_len + 4)] {
2885            for softcap in [None, Some(30.0)] {
2886                let got = super::causal_gqa_attention_prefill_shared_kv_windowed(
2887                    &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, softcap,
2888                    window,
2889                );
2890                let want = prefill_query_outer_reference(
2891                    &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, softcap,
2892                    window,
2893                );
2894                assert_eq!(got, want, "window {window:?} softcap {softcap:?}");
2895            }
2896        }
2897    }
2898
2899    #[test]
2900    fn tiled_prefill_gemm_is_bit_identical_across_awkward_shapes() {
2901        // `qk_tile` works a 4-query × 4-key register tile and `pv_tile`
2902        // an 8-query × 8-dim one, each with a row-at-a-time edge path
2903        // for what the tile does not cover. Every one of those edges,
2904        // and every `head_dim` width a real checkpoint uses, has to land
2905        // on the *same* arithmetic as the row-at-a-time form — so this
2906        // asserts bit equality against `prefill_query_outer_reference`,
2907        // not a tolerance.
2908        //
2909        // Swept: head_dim 64 (Llama/SmolLM2/Qwen3), 80 (Phi-4-mini), 128
2910        // (Qwen2.5/Mistral), 256 (Gemma-3); head counts that are not a
2911        // multiple of the tile; `n_q` below, on and off both tile
2912        // boundaries; GQA and MQA grouping; windows narrower than the
2913        // prompt (so the visible span is narrower than a query block);
2914        // and softcap on and off.
2915        let shapes = [
2916            (4usize, 4usize, 64usize),
2917            (6, 2, 64),
2918            (5, 1, 80),
2919            (3, 3, 128),
2920            (2, 1, 256),
2921        ];
2922        let batches = [(19usize, 5usize), (8, 0), (3, 7), (16, 1), (7, 0)];
2923        for &(n_heads, n_kv_heads, head_dim) in &shapes {
2924            let q_stride = n_heads * head_dim;
2925            let kv_stride = n_kv_heads * head_dim;
2926            for &(n_q, kv_prefix) in &batches {
2927                let kv_len = kv_prefix + n_q;
2928                let q: Vec<f32> = (0..n_q * q_stride)
2929                    .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2930                    .collect();
2931                let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2932                    .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2933                    .collect();
2934                let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2935                    .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2936                    .collect();
2937                for window in [None, Some(2), Some(5), Some(9), Some(kv_len + 3)] {
2938                    for softcap in [None, Some(30.0)] {
2939                        let got = super::causal_gqa_attention_prefill_shared_kv_windowed(
2940                            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix,
2941                            softcap, window,
2942                        );
2943                        let want = prefill_query_outer_reference(
2944                            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix,
2945                            softcap, window,
2946                        );
2947                        assert_eq!(
2948                            got, want,
2949                            "heads {n_heads}/{n_kv_heads} head_dim {head_dim} n_q {n_q} \
2950                             kv_prefix {kv_prefix} window {window:?} softcap {softcap:?}"
2951                        );
2952                    }
2953                }
2954            }
2955        }
2956    }
2957
2958    /// The blocked kernel and its query-outer reference share
2959    /// `softmax_row_exp_sum`, so their bit-equality test cannot see a
2960    /// wrong exponential -- it would be equally wrong on both sides.
2961    /// This is the test that can: the vectorised routine against
2962    /// `f32::exp`, i.e. against libm, which is what the kernel called
2963    /// before.
2964    ///
2965    /// Swept: every length from 0 through twice the widest vector so all
2966    /// four NEON and all eight AVX2 tail positions are hit; rows whose
2967    /// spread is far past the `-87` clamp, where the vector form floors
2968    /// at `~1.6e-38` and libm returns a true zero; a constant row, where
2969    /// every term is `exp(0)` and the sum must come out at exactly the
2970    /// row length; and a row of one.
2971    #[test]
2972    fn vectorised_softmax_row_matches_the_scalar_libm_form() {
2973        fn libm_reference(x: &[f32]) -> (Vec<f32>, f32) {
2974            let m = x.iter().fold(f32::NEG_INFINITY, |a, &s| a.max(s));
2975            let out: Vec<f32> = x.iter().map(|s| (s - m).exp()).collect();
2976            let mut l = 0f32;
2977            for &e in out.iter() {
2978                l += e;
2979            }
2980            (out, l)
2981        }
2982
2983        // `spread` scales the score range: 200.0 pushes the low tail
2984        // past the clamp, 0.0 makes every score identical.
2985        for spread in [1.0f32, 8.0, 200.0, 0.0] {
2986            for n in (0..=17).chain([31, 32, 33, 64, 127, 512]) {
2987                let row: Vec<f32> = (0..n)
2988                    .map(|i| ((i as f32) * 0.37 - 1.1).sin() * spread)
2989                    .collect();
2990                let (want, want_l) = libm_reference(&row);
2991
2992                let mut got = row.clone();
2993                let got_l = super::softmax_row_exp_sum(&mut got);
2994
2995                for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
2996                    assert!(
2997                        (g - w).abs() <= 1e-6 * w + 1e-30,
2998                        "spread {spread} n {n} slot {i}: vector {g} vs libm {w}"
2999                    );
3000                }
3001                assert!(
3002                    (got_l - want_l).abs() <= 1e-5 * want_l.max(1.0),
3003                    "spread {spread} n {n}: sum {got_l} vs libm {want_l}"
3004                );
3005                if n == 0 {
3006                    assert_eq!(got_l, 0.0, "an empty visible range normalises to nothing");
3007                }
3008                if spread == 0.0 && n > 0 {
3009                    // Every score equal means every term is `exp(0)`, and
3010                    // the routine has to return that as *exactly* 1.0 --
3011                    // an approximation that drifts at zero would bias
3012                    // every uniform attention row.
3013                    for (i, g) in got.iter().enumerate() {
3014                        assert_eq!(*g, 1.0, "n {n} slot {i}: exp(0) must be exact");
3015                    }
3016                }
3017            }
3018        }
3019    }
3020
3021    /// Replacing libm's `expf` and a sequential `f32` sum changes the
3022    /// last bits of every attention probability, and across a 26-layer
3023    /// prefill that is enough to move a greedy argmax on a near-tie. So
3024    /// "different from what the scalar form produced" is not the
3025    /// question worth asking, because the answer is yes and will stay
3026    /// yes. "Further from the true softmax" is the question, and this
3027    /// answers it against an `f64` ground truth.
3028    ///
3029    /// Measured on this sweep: the probabilities come out at the same
3030    /// accuracy as the scalar form (both within a factor of two of each
3031    /// other, both growing together with the spread of the row, because
3032    /// the shared error term is rounding `score - max` into `f32`, not
3033    /// the exponential); the normaliser comes out **better**, by 3x to
3034    /// 10x, because four partial sums is a pairwise reduction and
3035    /// `l += *s` down the row is not.
3036    #[test]
3037    fn the_vectorised_softmax_is_no_less_accurate_than_the_scalar_one() {
3038        for spread in [1.0f64, 6.0, 20.0] {
3039            for n in [64usize, 253, 512] {
3040                let row: Vec<f64> = (0..n)
3041                    .map(|i| ((i as f64) * 0.37 - 1.1).sin() * spread)
3042                    .collect();
3043
3044                // Ground truth: the same reduction in `f64`.
3045                let m64 = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
3046                let truth: Vec<f64> = row.iter().map(|s| (s - m64).exp()).collect();
3047                let truth_l: f64 = truth.iter().sum();
3048
3049                let f32_row: Vec<f32> = row.iter().map(|&s| s as f32).collect();
3050
3051                let mut vector = f32_row.clone();
3052                let vector_l = super::softmax_row_exp_sum(&mut vector);
3053                let mut scalar = f32_row.clone();
3054                let scalar_l = super::softmax_row_exp_sum_scalar(&mut scalar);
3055
3056                let worst = |got: &[f32]| -> f64 {
3057                    got.iter()
3058                        .zip(truth.iter())
3059                        .map(|(&g, &t)| ((g as f64) - t).abs() / t)
3060                        .fold(0.0, f64::max)
3061                };
3062                let (ev, es) = (worst(&vector), worst(&scalar));
3063                let lv = ((vector_l as f64) - truth_l).abs() / truth_l;
3064                let ls = ((scalar_l as f64) - truth_l).abs() / truth_l;
3065                let eps = f64::from(f32::EPSILON);
3066
3067                // Probabilities: the same accuracy, within a factor of
3068                // two either way. Neither form is the error term here --
3069                // rounding `score - max` into `f32` is, which is why the
3070                // error grows with the spread of the row and why both
3071                // forms grow with it together.
3072                assert!(
3073                    ev <= 2.0 * es.max(eps) && ev <= 64.0 * eps,
3074                    "spread {spread} n {n}: vector probabilities err {ev:e} \
3075                     against scalar {es:e}"
3076                );
3077
3078                // The normaliser: the vector form is the better one,
3079                // every time. Four (or eight) partial sums is a pairwise
3080                // reduction; `l += *s` down a 512-wide row is not.
3081                assert!(
3082                    lv <= ls.max(eps),
3083                    "spread {spread} n {n}: vector normaliser err {lv:e} \
3084                     against scalar {ls:e}"
3085                );
3086            }
3087        }
3088    }
3089
3090    /// The row max must be the true max whichever lane it lands in, and
3091    /// the largest term must come back as exactly `1.0`, because the
3092    /// caller divides by the sum rather than tracking a running maximum.
3093    #[test]
3094    fn softmax_row_finds_its_maximum_in_every_lane_position() {
3095        for n in 1usize..=20 {
3096            for peak in 0..n {
3097                let mut row: Vec<f32> = (0..n).map(|i| -(i as f32) - 3.0).collect();
3098                row[peak] = 12.5;
3099                let l = super::softmax_row_exp_sum(&mut row);
3100                assert_eq!(row[peak], 1.0, "n {n} peak {peak}: the max term is exp(0)");
3101                for (i, &p) in row.iter().enumerate() {
3102                    assert!(p <= 1.0, "n {n} peak {peak} slot {i}: {p} exceeds the max");
3103                }
3104                assert!(l >= 1.0, "n {n} peak {peak}: sum {l} must include the max");
3105            }
3106        }
3107    }
3108
3109    use super::*;
3110
3111    #[test]
3112    fn simd_dot_f32_matches_scalar_across_lengths() {
3113        // Cover exact-multiple and tail lengths around the SIMD width.
3114        for n in [1usize, 3, 4, 7, 8, 15, 16, 63, 128, 129] {
3115            let a: Vec<f32> = (0..n).map(|i| ((i as f32) * 0.31 - 2.0).sin()).collect();
3116            let b: Vec<f32> = (0..n).map(|i| ((i as f32) * 0.17 + 1.0).cos()).collect();
3117            let simd = dot_f32(&a, &b);
3118            let scalar: f32 = a.iter().zip(&b).map(|(x, y)| x * y).sum();
3119            assert!(
3120                (simd - scalar).abs() <= 1e-4 * scalar.abs().max(1.0),
3121                "n={n} simd={simd} scalar={scalar}"
3122            );
3123        }
3124    }
3125
3126    #[test]
3127    fn rope_preserves_vector_norm() {
3128        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3129        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3130        apply_rope(&mut v, 5, 10000.0);
3131        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3132        assert!(
3133            (norm_before - norm_after).abs() < 1e-4,
3134            "RoPE is a rotation and must preserve norm"
3135        );
3136    }
3137
3138    #[test]
3139    fn rope_back_inverts_rope() {
3140        let original = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3141        let mut v = original.clone();
3142        apply_rope(&mut v, 11, 10000.0);
3143        apply_rope_back(&mut v, 11, 10000.0);
3144        for (a, b) in v.iter().zip(original.iter()) {
3145            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3146        }
3147    }
3148
3149    #[test]
3150    fn rope_interleaved_back_inverts_interleaved() {
3151        let original = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3152        let mut v = original.clone();
3153        apply_rope_interleaved(&mut v, 11, 10000.0);
3154        apply_rope_interleaved_back(&mut v, 11, 10000.0);
3155        for (a, b) in v.iter().zip(original.iter()) {
3156            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3157        }
3158    }
3159
3160    #[test]
3161    fn rope_at_position_zero_is_identity() {
3162        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3163        let original = v.clone();
3164        apply_rope(&mut v, 0, 10000.0);
3165        for (a, b) in v.iter().zip(original.iter()) {
3166            assert!((a - b).abs() < 1e-5);
3167        }
3168    }
3169
3170    #[test]
3171    fn rope_with_all_ones_freq_factors_matches_plain_rope() {
3172        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3173        let mut plain = with_factors.clone();
3174        let ones = vec![1.0; 3];
3175        apply_rope_with_freq_factors(&mut with_factors, 7, 10000.0, &ones);
3176        apply_rope(&mut plain, 7, 10000.0);
3177        for (a, b) in with_factors.iter().zip(plain.iter()) {
3178            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3179        }
3180    }
3181
3182    #[test]
3183    fn rope_with_freq_factors_diverges_from_plain_rope_when_factors_are_not_one() {
3184        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3185        let mut plain = with_factors.clone();
3186        let factors = vec![0.5, 2.0, 1.0];
3187        apply_rope_with_freq_factors(&mut with_factors, 7, 10000.0, &factors);
3188        apply_rope(&mut plain, 7, 10000.0);
3189        let differs = with_factors
3190            .iter()
3191            .zip(plain.iter())
3192            .any(|(a, b)| (a - b).abs() > 1e-4);
3193        assert!(differs, "non-1.0 freq_factors must change the rotation");
3194    }
3195
3196    #[test]
3197    fn rope_with_freq_factors_preserves_vector_norm() {
3198        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3199        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3200        apply_rope_with_freq_factors(&mut v, 5, 10000.0, &[0.8, 1.3]);
3201        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3202        assert!((norm_before - norm_after).abs() < 1e-4);
3203    }
3204
3205    /// The correction range's high end is clamped to `rotary_dim - 1`,
3206    /// which is *above* the ramp's own last index (`rotary_dim/2 - 1`),
3207    /// so the ramp never reaches `1.0` and the longest-wavelength bands
3208    /// stay partly extrapolated.
3209    ///
3210    /// **This test fails if `high` is clamped the naive way** (to
3211    /// `rotary_dim / 2 - 1`): that makes `ramp == 1.0` at the last band,
3212    /// whose divisor then becomes the full scaling factor `8.0` instead
3213    /// of the reference's `2.5366`. Numbers below are computed by hand
3214    /// from `_find_correction_dim` (`rotary.py:161`), not from this
3215    /// implementation: for `rotary_dim` 64, base 10000, original context
3216    /// 131072, `low = floor(22.5134) = 22` and
3217    /// `high = ceil(34.5546) = 35`.
3218    #[test]
3219    fn yarn_high_is_clamped_to_rotary_dim_minus_one_not_half_minus_one() {
3220        let scaling = YarnScaling::new(8.0, 131_072);
3221        let (low, high) = yarn_correction_range(scaling, 64, 10_000.0);
3222        assert!((low - 22.0).abs() < 1e-9, "low was {low}");
3223        assert!((high - 35.0).abs() < 1e-9, "high was {high}");
3224
3225        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3226        let last = factors[31];
3227        let ramp = (31.0 - 22.0) / (35.0 - 22.0);
3228        let want = 1.0 / (ramp / 8.0 + (1.0 - ramp));
3229        assert!(
3230            (last - want).abs() < 1e-4,
3231            "last band divisor {last} must be the reference's {want}"
3232        );
3233        assert!(
3234            (last - 8.0).abs() > 1.0,
3235            "clamping high to rotary_dim/2 - 1 would fully interpolate this band \
3236             (divisor 8.0, the whole factor); got {last}"
3237        );
3238    }
3239
3240    /// Band-by-band against the reference ramp
3241    /// (`rotary.py:181-187`), with `low`/`high` computed by hand as in
3242    /// the test above: bands at or below `low` are pure extrapolation
3243    /// (divisor exactly 1.0) and each band past it interpolates by
3244    /// `1 / (ramp/factor + 1 - ramp)`.
3245    #[test]
3246    fn yarn_freq_factors_match_the_reference_ramp_formula_band_by_band() {
3247        let scaling = YarnScaling::new(8.0, 131_072);
3248        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3249        assert_eq!(factors.len(), 32, "one divisor per rotation band");
3250        for band in [0usize, 10, 22] {
3251            assert!(
3252                (factors[band] - 1.0).abs() < 1e-6,
3253                "band {band} is at or below low=22 and must be left extrapolated, \
3254                 got {}",
3255                factors[band]
3256            );
3257        }
3258        for band in [23usize, 27, 31] {
3259            let ramp = (band as f32 - 22.0) / (35.0 - 22.0);
3260            let want = 1.0 / (ramp / 8.0 + (1.0 - ramp));
3261            assert!(
3262                (factors[band] - want).abs() < 1e-4,
3263                "band {band}: got {}, reference {want}",
3264                factors[band]
3265            );
3266        }
3267    }
3268
3269    /// A collapsed correction range (`low == high`, which
3270    /// `truncate: false` with `beta_fast == beta_slow` produces) is
3271    /// nudged by `+0.001`, making the ramp a step at `low`: the band
3272    /// below stays fully extrapolated and the band above is fully
3273    /// interpolated at the whole factor.
3274    ///
3275    /// **This test fails if the collapse is handled by flooring the gap
3276    /// at 1** (`high = low + 1`), the other obvious repair: band 23 is
3277    /// then only `0.4866` of the way up the ramp and its divisor is
3278    /// `1.7414`, not `8.0`.
3279    #[test]
3280    fn yarn_nudges_a_collapsed_correction_range_instead_of_flooring_the_gap_at_one() {
3281        let scaling = YarnScaling {
3282            beta_slow: 32.0,
3283            truncate: false,
3284            ..YarnScaling::new(8.0, 131_072)
3285        };
3286        let (low, high) = yarn_correction_range(scaling, 64, 10_000.0);
3287        // Hand-computed: _find_correction_dim(32) = 22.513440...
3288        assert!((low - 22.513_44).abs() < 1e-4, "low was {low}");
3289        assert!(
3290            (high - low - 0.001).abs() < 1e-9,
3291            "high must be low + 0.001, got {high}"
3292        );
3293
3294        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3295        assert!(
3296            (factors[22] - 1.0).abs() < 1e-6,
3297            "band below the step must be untouched, got {}",
3298            factors[22]
3299        );
3300        assert!(
3301            (factors[23] - 8.0).abs() < 1e-4,
3302            "band above the step must take the whole factor (a gap of 1 would \
3303             give 1.7414); got {}",
3304            factors[23]
3305        );
3306    }
3307
3308    /// `factor = 1.0` means "the served context is the trained context":
3309    /// every band's divisor must be exactly 1.0, i.e. mathematically the
3310    /// same rotation as no scaling at all. A checkpoint that declares
3311    /// YaRN with a no-op factor must not have its RoPE moved.
3312    #[test]
3313    fn yarn_with_a_factor_of_one_leaves_every_band_untouched() {
3314        let factors = yarn_freq_factors(YarnScaling::new(1.0, 4096), 32, 10_000.0);
3315        for (band, f) in factors.iter().enumerate() {
3316            assert!((f - 1.0).abs() < 1e-6, "band {band} moved to {f}");
3317        }
3318    }
3319
3320    /// The divisors are only a re-expression of the reference's
3321    /// rewritten `inv_freq`, so rotating through
3322    /// [`apply_rope_with_freq_factors`] must land on exactly the angle
3323    /// the reference's `inv_freq_new` implies. Checked against the
3324    /// reference formula (`inv_freq * (ramp/factor + 1 - ramp)`)
3325    /// evaluated here, not against this module's own divisor.
3326    #[test]
3327    fn yarn_divisors_reproduce_the_references_rewritten_frequencies() {
3328        let scaling = YarnScaling::new(8.0, 131_072);
3329        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3330
3331        let band = 31usize;
3332        let pos = 1024usize;
3333        let mut v = vec![0.0f32; 64];
3334        v[band] = 1.0;
3335        apply_rope_with_freq_factors(&mut v, pos, 10_000.0, &factors);
3336
3337        let ramp = (band as f64 - 22.0) / (35.0 - 22.0);
3338        let inv_freq = 1.0 / 10_000f64.powf((2 * band) as f64 / 64.0);
3339        let inv_freq_new = inv_freq * (ramp / 8.0 + (1.0 - ramp));
3340        let angle = pos as f64 * inv_freq_new;
3341        assert!(
3342            (v[band] as f64 - angle.cos()).abs() < 1e-5,
3343            "cos: {} vs {}",
3344            v[band],
3345            angle.cos()
3346        );
3347        assert!(
3348            (v[band + 32] as f64 - angle.sin()).abs() < 1e-5,
3349            "sin: {} vs {}",
3350            v[band + 32],
3351            angle.sin()
3352        );
3353    }
3354
3355    /// The proportional arm spaces frequencies over the *full* head
3356    /// while only the first `rotary_dim` channels rotate. Values are
3357    /// hand-computed from `rotary.py:103`'s
3358    /// `base ** (arange(0, head_size, 2) / head_size)` against this
3359    /// crate's own `base ** (2i / rotary_dim)` spacing: the divisor is
3360    /// their ratio.
3361    #[test]
3362    fn proportional_freq_factors_respace_frequencies_over_the_full_head() {
3363        let factors = proportional_freq_factors(128, 96, 10_000.0);
3364        assert_eq!(factors.len(), 48, "one divisor per rotated band");
3365        assert!((factors[0] - 1.0).abs() < 1e-6, "band 0 is 1/1");
3366        // 10000^(2/128 - 2/96) = 0.95316188...
3367        assert!(
3368            (factors[1] - 0.953_161_9).abs() < 1e-5,
3369            "band 1 was {}",
3370            factors[1]
3371        );
3372        // 10000^(94/128 - 94/96) = 0.10491397...
3373        assert!(
3374            (factors[47] - 0.104_913_97).abs() < 1e-5,
3375            "last band was {}",
3376            factors[47]
3377        );
3378    }
3379
3380    /// Full-width rope is the case where both spacings coincide, so the
3381    /// arm must be a no-op there rather than quietly re-scaling every
3382    /// band of an ordinary checkpoint.
3383    #[test]
3384    fn proportional_freq_factors_are_all_ones_when_the_whole_head_rotates() {
3385        for f in proportional_freq_factors(128, 128, 500_000.0) {
3386            assert!((f - 1.0).abs() < 1e-6, "full-width band moved to {f}");
3387        }
3388    }
3389
3390    #[test]
3391    fn rope_interleaved_preserves_vector_norm() {
3392        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3393        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3394        apply_rope_interleaved(&mut v, 5, 10000.0);
3395        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3396        assert!(
3397            (norm_before - norm_after).abs() < 1e-4,
3398            "RoPE is a rotation and must preserve norm"
3399        );
3400    }
3401
3402    #[test]
3403    fn rope_interleaved_at_position_zero_is_identity() {
3404        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3405        let original = v.clone();
3406        apply_rope_interleaved(&mut v, 0, 10000.0);
3407        for (a, b) in v.iter().zip(original.iter()) {
3408            assert!((a - b).abs() < 1e-5);
3409        }
3410    }
3411
3412    #[test]
3413    fn rope_interleaved_rotates_adjacent_pairs_not_split_halves() {
3414        // With a single frequency band (dim=2), interleaved and split-half
3415        // RoPE are mathematically identical (both rotate the one (v[0],
3416        // v[1]) pair). The two conventions only diverge once dim > 2 and
3417        // there's more than one frequency band to route pairs into --
3418        // that's the real bug class this test guards against: mixing up
3419        // which components get paired together.
3420        let mut interleaved = vec![1.0, 0.0, 0.0, 1.0];
3421        let mut split_half = interleaved.clone();
3422        apply_rope_interleaved(&mut interleaved, 3, 10000.0);
3423        apply_rope(&mut split_half, 3, 10000.0);
3424        // Different frequency assigned to each pair in the two
3425        // conventions (interleaved pairs (0,1)+(2,3), split-half pairs
3426        // (0,2)+(1,3)) so with two distinct frequency bands the outputs
3427        // must differ.
3428        let differs = interleaved
3429            .iter()
3430            .zip(split_half.iter())
3431            .any(|(a, b)| (a - b).abs() > 1e-4);
3432        assert!(differs, "the two RoPE conventions must not coincide here");
3433    }
3434
3435    #[test]
3436    fn rope_interleaved_with_all_ones_freq_factors_matches_plain_interleaved() {
3437        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3438        let mut plain = with_factors.clone();
3439        let ones = vec![1.0; 3];
3440        apply_rope_interleaved_with_freq_factors(&mut with_factors, 7, 10000.0, &ones);
3441        apply_rope_interleaved(&mut plain, 7, 10000.0);
3442        for (a, b) in with_factors.iter().zip(plain.iter()) {
3443            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3444        }
3445    }
3446
3447    #[test]
3448    fn rope_interleaved_with_freq_factors_diverges_when_factors_are_not_one() {
3449        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3450        let mut plain = with_factors.clone();
3451        let factors = vec![0.5, 2.0, 1.0];
3452        apply_rope_interleaved_with_freq_factors(&mut with_factors, 7, 10000.0, &factors);
3453        apply_rope_interleaved(&mut plain, 7, 10000.0);
3454        let differs = with_factors
3455            .iter()
3456            .zip(plain.iter())
3457            .any(|(a, b)| (a - b).abs() > 1e-4);
3458        assert!(differs, "non-1.0 freq_factors must change the rotation");
3459    }
3460
3461    #[test]
3462    fn rope_interleaved_with_freq_factors_preserves_vector_norm() {
3463        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3464        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3465        apply_rope_interleaved_with_freq_factors(&mut v, 5, 10000.0, &[0.8, 1.3]);
3466        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3467        assert!((norm_before - norm_after).abs() < 1e-4);
3468    }
3469
3470    #[test]
3471    fn attention_with_single_position_returns_that_value() {
3472        // With one cached position, attention weight is trivially 1.0,
3473        // so output must equal that single V vector regardless of Q/K.
3474        let q = vec![1.0, 0.0]; // 1 head, head_dim=2
3475        let k_cache = vec![0.5, 0.5]; // seq_len=1, 1 kv head
3476        let v_cache = vec![9.0, -3.0];
3477        let out = causal_gqa_attention(&q, &k_cache, &v_cache, 1, 1, 2, 1);
3478        assert!((out[0] - 9.0).abs() < 1e-4);
3479        assert!((out[1] - (-3.0)).abs() < 1e-4);
3480    }
3481
3482    #[test]
3483    fn gqa_group_mapping_shares_kv_heads_correctly() {
3484        // 4 query heads, 2 kv heads -> heads 0,1 use kv head 0; heads 2,3 use kv head 1.
3485        let head_dim = 2;
3486        let q = vec![
3487            1.0, 0.0, // head 0
3488            1.0, 0.0, // head 1
3489            1.0, 0.0, // head 2
3490            1.0, 0.0, // head 3
3491        ];
3492        // seq_len = 1, 2 kv heads
3493        let k_cache = vec![1.0, 0.0, 1.0, 0.0];
3494        let v_cache = vec![100.0, 100.0, 200.0, 200.0];
3495        let out = causal_gqa_attention(&q, &k_cache, &v_cache, 4, 2, head_dim, 1);
3496        // heads 0,1 -> kv head 0 -> v = [100,100]; heads 2,3 -> kv head 1 -> v=[200,200]
3497        assert_eq!(&out[0..2], &[100.0, 100.0][..]);
3498        assert_eq!(&out[2..4], &[100.0, 100.0][..]);
3499        assert_eq!(&out[4..6], &[200.0, 200.0][..]);
3500        assert_eq!(&out[6..8], &[200.0, 200.0][..]);
3501    }
3502
3503    #[test]
3504    fn prefill_gqa_matches_per_token_causal() {
3505        let n_heads = 4;
3506        let n_kv_heads = 2;
3507        let head_dim = 4;
3508        let seq_len = 5;
3509        let q: Vec<f32> = (0..seq_len * n_heads * head_dim)
3510            .map(|i| (i as f32 * 0.13).sin())
3511            .collect();
3512        let k: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3513            .map(|i| (i as f32 * 0.19).cos())
3514            .collect();
3515        let v: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3516            .map(|i| (i as f32 * 0.07).sin())
3517            .collect();
3518        let batched =
3519            causal_gqa_attention_prefill(&q, &k, &v, n_heads, n_kv_heads, head_dim, seq_len);
3520        let q_stride = n_heads * head_dim;
3521        let kv_stride = n_kv_heads * head_dim;
3522        for t in 0..seq_len {
3523            let expect = causal_gqa_attention(
3524                &q[t * q_stride..(t + 1) * q_stride],
3525                &k[..(t + 1) * kv_stride],
3526                &v[..(t + 1) * kv_stride],
3527                n_heads,
3528                n_kv_heads,
3529                head_dim,
3530                t + 1,
3531            );
3532            let got = &batched[t * q_stride..(t + 1) * q_stride];
3533            for (a, b) in got.iter().zip(expect.iter()) {
3534                assert!((a - b).abs() < 1e-5, "t={t}: {a} vs {b}");
3535            }
3536        }
3537    }
3538
3539    #[test]
3540    fn windowed_attention_with_window_covering_full_history_matches_full_causal() {
3541        let n_heads = 2;
3542        let n_kv_heads = 1;
3543        let head_dim = 3;
3544        let seq_len = 4;
3545
3546        let q: Vec<f32> = (0..n_heads * head_dim)
3547            .map(|i| (i as f32 * 0.3).sin())
3548            .collect();
3549        let k_cache: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3550            .map(|i| (i as f32 * 0.17).cos())
3551            .collect();
3552        let v_cache: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3553            .map(|i| (i as f32 * 0.11).sin())
3554            .collect();
3555
3556        let full = causal_gqa_attention(
3557            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, seq_len,
3558        );
3559        let windowed = causal_gqa_attention_windowed(
3560            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, seq_len, seq_len,
3561        );
3562        assert_eq!(full.len(), windowed.len());
3563        for (a, b) in full.iter().zip(windowed.iter()) {
3564            assert_eq!(
3565                a.to_bits(),
3566                b.to_bits(),
3567                "window >= seq_len must be bit-identical to full causal"
3568            );
3569        }
3570    }
3571
3572    #[test]
3573    fn windowed_attention_ignores_positions_outside_the_window() {
3574        // 1 head, seq_len=3, window=1: only the current position (t=2)
3575        // should ever be attended to, so the output must equal exactly
3576        // that position's V vector regardless of Q/K -- masking every
3577        // earlier position out means there is exactly one candidate
3578        // left, and softmax over one candidate is trivially 1.0.
3579        let head_dim = 2;
3580        let q = vec![1.0, 0.0];
3581        let k_cache = vec![9.0, -9.0, 0.5, 0.5, -3.0, 7.0]; // seq_len=3, 1 kv head
3582        let v_cache = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0];
3583        let out = causal_gqa_attention_windowed(&q, &k_cache, &v_cache, 1, 1, head_dim, 3, 1);
3584        assert!((out[0] - 50.0).abs() < 1e-4);
3585        assert!((out[1] - 60.0).abs() < 1e-4);
3586    }
3587
3588    #[test]
3589    fn paged_attention_matches_contiguous_attention_bit_identical() {
3590        use crate::cache::{PagedKvCache, PagedKvStore};
3591
3592        let n_heads = 4;
3593        let n_kv_heads = 2;
3594        let head_dim = 3;
3595        let block_size = 2;
3596        let seq_len = 5;
3597
3598        // Deterministic pseudo-random-ish K/V/Q values, no RNG needed.
3599        let k_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3600            .map(|i| ((i * 7 + 1) % 13) as f32 * 0.1)
3601            .collect();
3602        let v_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3603            .map(|i| ((i * 5 + 3) % 11) as f32 * 0.1)
3604            .collect();
3605        let q: Vec<f32> = (0..n_heads * head_dim)
3606            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3607            .collect();
3608
3609        let contiguous =
3610            causal_gqa_attention(&q, &k_flat, &v_flat, n_heads, n_kv_heads, head_dim, seq_len);
3611
3612        let mut store = PagedKvStore::new(block_size, seq_len, n_kv_heads, head_dim);
3613        let mut cache = PagedKvCache::new();
3614        for t in 0..seq_len {
3615            let start = t * n_kv_heads * head_dim;
3616            let end = start + n_kv_heads * head_dim;
3617            cache
3618                .push(&mut store, &k_flat[start..end], &v_flat[start..end])
3619                .expect("store sized for seq_len blocks, must not exhaust");
3620        }
3621
3622        let paged = causal_gqa_attention_paged(
3623            &q,
3624            &store,
3625            cache.block_table(),
3626            n_heads,
3627            n_kv_heads,
3628            head_dim,
3629            seq_len,
3630        );
3631
3632        assert_eq!(contiguous.len(), paged.len());
3633        for (a, b) in contiguous.iter().zip(paged.iter()) {
3634            assert_eq!(a.to_bits(), b.to_bits(), "paged path must be bit-identical");
3635        }
3636    }
3637
3638    /// A helper for the paged/contiguous comparisons below: the same
3639    /// K/V pushed into a paged store, so only the ADDRESSING differs
3640    /// between the two kernels under test.
3641    fn paged_fixture(
3642        seq_len: usize,
3643        n_kv_heads: usize,
3644        head_dim: usize,
3645        block_size: usize,
3646    ) -> (Vec<f32>, Vec<f32>, crate::cache::PagedKvStore, Vec<usize>) {
3647        use crate::cache::{PagedKvCache, PagedKvStore};
3648        let k_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3649            .map(|i| ((i * 7 + 1) % 13) as f32 * 0.1)
3650            .collect();
3651        let v_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3652            .map(|i| ((i * 5 + 3) % 11) as f32 * 0.1)
3653            .collect();
3654        let mut store = PagedKvStore::new(block_size, seq_len, n_kv_heads, head_dim);
3655        let mut cache = PagedKvCache::new();
3656        for t in 0..seq_len {
3657            let start = t * n_kv_heads * head_dim;
3658            let end = start + n_kv_heads * head_dim;
3659            cache
3660                .push(&mut store, &k_flat[start..end], &v_flat[start..end])
3661                .expect("store sized for seq_len blocks, must not exhaust");
3662        }
3663        let table = cache.block_table().to_vec();
3664        (k_flat, v_flat, store, table)
3665    }
3666
3667    /// The paged kernel's sink term must be BIT-identical to the
3668    /// contiguous one, not merely close.
3669    ///
3670    /// This is what let `forward_token_paged` stop refusing gpt-oss.
3671    /// The whole premise of moving a model onto paged KV is that its
3672    /// distribution does not change, so "within tolerance" is not the
3673    /// bar -- a distribution that differs in the last bit is still a
3674    /// different distribution, and it would show up as a model that
3675    /// answers differently depending on which cache it happened to be
3676    /// served from.
3677    #[test]
3678    fn the_paged_sink_term_is_bit_identical_to_the_contiguous_one() {
3679        let (n_heads, n_kv_heads, head_dim, seq_len, block_size) = (4, 2, 3, 5, 2);
3680        let (k_flat, v_flat, store, table) =
3681            paged_fixture(seq_len, n_kv_heads, head_dim, block_size);
3682        let q: Vec<f32> = (0..n_heads * head_dim)
3683            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3684            .collect();
3685
3686        // A spread of sinks, including one that dominates and one that
3687        // is negligible, so the comparison covers both ends of the
3688        // online-softmax rescale rather than a single middling value.
3689        let sinks = vec![-30.0f32, 0.0, 1.5, 30.0];
3690        let contiguous = causal_gqa_attention_sinks(
3691            &q, &k_flat, &v_flat, n_heads, n_kv_heads, head_dim, seq_len, None, &sinks,
3692        );
3693        let paged = causal_gqa_attention_paged_sinks(
3694            &q,
3695            &store,
3696            &table,
3697            n_heads,
3698            n_kv_heads,
3699            head_dim,
3700            seq_len,
3701            None,
3702            Some(&sinks),
3703            None,
3704        );
3705        assert_eq!(contiguous.len(), paged.len());
3706        for (i, (a, b)) in contiguous.iter().zip(paged.iter()).enumerate() {
3707            assert_eq!(a.to_bits(), b.to_bits(), "element {i}: {a} vs {b}");
3708        }
3709    }
3710
3711    /// The window arm too, at a width that really drops positions --
3712    /// and at one that covers the whole history, which must degenerate
3713    /// to full causal rather than to an off-by-one.
3714    #[test]
3715    fn the_paged_window_arm_is_bit_identical_to_the_contiguous_one() {
3716        let (n_heads, n_kv_heads, head_dim, seq_len, block_size) = (4, 2, 3, 7, 2);
3717        let (k_flat, v_flat, store, table) =
3718            paged_fixture(seq_len, n_kv_heads, head_dim, block_size);
3719        let q: Vec<f32> = (0..n_heads * head_dim)
3720            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3721            .collect();
3722        let sinks = vec![0.5f32; n_heads];
3723
3724        for window in [1usize, 2, 3, 6, 7, 99] {
3725            let contiguous = causal_gqa_attention_sinks(
3726                &q,
3727                &k_flat,
3728                &v_flat,
3729                n_heads,
3730                n_kv_heads,
3731                head_dim,
3732                seq_len,
3733                Some(window),
3734                &sinks,
3735            );
3736            let paged = causal_gqa_attention_paged_sinks(
3737                &q,
3738                &store,
3739                &table,
3740                n_heads,
3741                n_kv_heads,
3742                head_dim,
3743                seq_len,
3744                Some(window),
3745                Some(&sinks),
3746                None,
3747            );
3748            for (i, (a, b)) in contiguous.iter().zip(paged.iter()).enumerate() {
3749                assert_eq!(
3750                    a.to_bits(),
3751                    b.to_bits(),
3752                    "window {window} element {i}: {a} vs {b}"
3753                );
3754            }
3755        }
3756    }
3757
3758    /// With no sinks and no window it must reproduce the plain paged
3759    /// kernel exactly, so the new entry point is a strict superset
3760    /// rather than a second implementation that drifts from it.
3761    #[test]
3762    fn the_paged_sink_kernel_without_sinks_or_window_is_the_plain_paged_kernel() {
3763        let (n_heads, n_kv_heads, head_dim, seq_len, block_size) = (4, 2, 3, 5, 2);
3764        let (_k, _v, store, table) = paged_fixture(seq_len, n_kv_heads, head_dim, block_size);
3765        let q: Vec<f32> = (0..n_heads * head_dim)
3766            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3767            .collect();
3768
3769        let plain =
3770            causal_gqa_attention_paged(&q, &store, &table, n_heads, n_kv_heads, head_dim, seq_len);
3771        let via_sinks = causal_gqa_attention_paged_sinks(
3772            &q, &store, &table, n_heads, n_kv_heads, head_dim, seq_len, None, None, None,
3773        );
3774        for (i, (a, b)) in plain.iter().zip(via_sinks.iter()).enumerate() {
3775            assert_eq!(a.to_bits(), b.to_bits(), "element {i}");
3776        }
3777    }
3778
3779    #[test]
3780    fn mla_attention_with_single_position_returns_that_value() {
3781        // Same reasoning as `attention_with_single_position_returns_that_value`,
3782        // but with distinct qk/v head dims (5 vs 3) to exercise the one real
3783        // difference from `causal_gqa_attention`.
3784        let q = vec![1.0, 0.0, 0.0, 0.0, 0.0]; // 1 head, qk_head_dim=5
3785        let k_cache = vec![0.2, 0.2, 0.2, 0.2, 0.2]; // seq_len=1
3786        let v_cache = vec![9.0, -3.0, 1.0]; // v_head_dim=3
3787        let out = causal_mla_attention(&q, &k_cache, &v_cache, 1, 5, 3, 1);
3788        assert_eq!(out.len(), 3);
3789        assert!((out[0] - 9.0).abs() < 1e-4);
3790        assert!((out[1] - (-3.0)).abs() < 1e-4);
3791        assert!((out[2] - 1.0).abs() < 1e-4);
3792    }
3793
3794    #[test]
3795    fn mla_attention_every_head_gets_its_own_kv_no_grouping() {
3796        // Unlike GQA, MLA has no shared-kv-head grouping: with 2 heads and
3797        // 2 cached kv-head-slots, head 0 must only ever see kv slot 0 and
3798        // head 1 only kv slot 1.
3799        let qk_head_dim = 2;
3800        let v_head_dim = 2;
3801        let q = vec![1.0, 0.0, 1.0, 0.0]; // 2 heads
3802        let k_cache = vec![1.0, 0.0, 1.0, 0.0]; // seq_len=1, 2 heads
3803        let v_cache = vec![100.0, 100.0, 200.0, 200.0];
3804        let out = causal_mla_attention(&q, &k_cache, &v_cache, 2, qk_head_dim, v_head_dim, 1);
3805        assert_eq!(&out[0..2], &[100.0, 100.0][..]);
3806        assert_eq!(&out[2..4], &[200.0, 200.0][..]);
3807    }
3808
3809    #[test]
3810    fn lightning_indexer_topk_keeps_all_positions_when_top_k_covers_them() {
3811        let indexer_q = vec![vec![1.0, 0.0]];
3812        let indexer_keys = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.1, 0.9]];
3813        let indexer_weights = vec![1.0];
3814        let kept = lightning_indexer_topk(&indexer_q, &indexer_keys, &indexer_weights, 10);
3815        assert_eq!(kept, vec![0, 1, 2]);
3816    }
3817
3818    #[test]
3819    fn lightning_indexer_topk_selects_highest_scoring_positions() {
3820        // Query aligned with key 0 (dot=1.0), partially with key 2 (dot=0.9),
3821        // orthogonal to key 1 (dot=0.0, relu'd score 0). Top-2 must be {0, 2}.
3822        let indexer_q = vec![vec![1.0, 0.0]];
3823        let indexer_keys = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![0.9, 0.1]];
3824        let indexer_weights = vec![1.0];
3825        let kept = lightning_indexer_topk(&indexer_q, &indexer_keys, &indexer_weights, 2);
3826        assert_eq!(kept, vec![0, 2]);
3827    }
3828
3829    #[test]
3830    fn lightning_indexer_topk_relu_zeroes_negative_dot_products() {
3831        // Key 1's dot product with the query is negative; ReLU floors its
3832        // score at 0, so it must lose to key 0 (positive) even at top_k=1.
3833        let indexer_q = vec![vec![1.0, 0.0]];
3834        let indexer_keys = vec![vec![0.3, 0.0], vec![-1.0, 0.0]];
3835        let indexer_weights = vec![1.0];
3836        let kept = lightning_indexer_topk(&indexer_q, &indexer_keys, &indexer_weights, 1);
3837        assert_eq!(kept, vec![0]);
3838    }
3839
3840    #[test]
3841    fn mla_attention_sparse_with_all_positions_visible_matches_full_causal() {
3842        let qk_head_dim = 3;
3843        let v_head_dim = 2;
3844        let seq_len = 4;
3845        let n_heads = 2;
3846        let q: Vec<f32> = (0..n_heads * qk_head_dim).map(|i| i as f32 * 0.1).collect();
3847        let k_cache: Vec<f32> = (0..seq_len * n_heads * qk_head_dim)
3848            .map(|i| (i as f32 * 0.05).sin())
3849            .collect();
3850        let v_cache: Vec<f32> = (0..seq_len * n_heads * v_head_dim)
3851            .map(|i| (i as f32 * 0.05).cos())
3852            .collect();
3853
3854        let full = causal_mla_attention(
3855            &q,
3856            &k_cache,
3857            &v_cache,
3858            n_heads,
3859            qk_head_dim,
3860            v_head_dim,
3861            seq_len,
3862        );
3863        let visible: Vec<usize> = (0..seq_len).collect();
3864        let sparse = causal_mla_attention_sparse(
3865            &q,
3866            &k_cache,
3867            &v_cache,
3868            n_heads,
3869            qk_head_dim,
3870            v_head_dim,
3871            seq_len,
3872            &visible,
3873        );
3874
3875        assert_eq!(full.len(), sparse.len());
3876        for (a, b) in full.iter().zip(sparse.iter()) {
3877            assert!((a - b).abs() < 1e-6, "full={a} sparse={b}");
3878        }
3879    }
3880
3881    #[test]
3882    fn mla_attention_sparse_ignores_positions_outside_visible_set() {
3883        // Only position 0 is visible; a wildly different value at position 1
3884        // must have zero influence on the output.
3885        let qk_head_dim = 2;
3886        let v_head_dim = 1;
3887        let q = vec![1.0, 0.0];
3888        let k_cache = vec![1.0, 0.0, 1.0, 0.0]; // seq_len=2, identical keys
3889        let v_cache = vec![5.0, 999.0]; // position 0 -> 5.0, position 1 -> 999.0
3890        let out = causal_mla_attention_sparse(
3891            &q,
3892            &k_cache,
3893            &v_cache,
3894            1,
3895            qk_head_dim,
3896            v_head_dim,
3897            2,
3898            &[0],
3899        );
3900        assert_eq!(out.len(), 1);
3901        assert!((out[0] - 5.0).abs() < 1e-6);
3902    }
3903
3904    #[test]
3905    fn attn_logit_softcap_changes_output_vs_uncapped() {
3906        // Softcap must change the attended output relative to the uncapped
3907        // path (and must not be a no-op identity for large scores).
3908        let n_heads = 2;
3909        let n_kv_heads = 1;
3910        let head_dim = 4;
3911        let seq_len = 3;
3912        let q: Vec<f32> = (0..n_heads * head_dim)
3913            .map(|i| (i as f32 + 1.0) * 2.5)
3914            .collect();
3915        let k: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3916            .map(|i| (i as f32 * 0.7).sin() * 3.0)
3917            .collect();
3918        let v: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3919            .map(|i| (i as f32 * 0.3).cos())
3920            .collect();
3921        let plain = causal_gqa_attention(&q, &k, &v, n_heads, n_kv_heads, head_dim, seq_len);
3922        let capped = causal_gqa_attention_softcap(
3923            &q,
3924            &k,
3925            &v,
3926            n_heads,
3927            n_kv_heads,
3928            head_dim,
3929            seq_len,
3930            Some(30.0),
3931        );
3932        assert_eq!(plain.len(), capped.len());
3933        let differs = plain
3934            .iter()
3935            .zip(capped.iter())
3936            .any(|(a, b)| (a - b).abs() > 1e-5);
3937        assert!(differs, "softcap must change attention output");
3938        // Softcap None / <=0 must match the uncapped path.
3939        let none =
3940            causal_gqa_attention_softcap(&q, &k, &v, n_heads, n_kv_heads, head_dim, seq_len, None);
3941        for (a, b) in plain.iter().zip(none.iter()) {
3942            assert!((a - b).abs() < 1e-6);
3943        }
3944    }
3945}