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    let q_stride = n_heads * head_dim;
1044    let kv_stride = n_kv_heads * head_dim;
1045    assert_eq!(q.len(), n_q * q_stride);
1046    let kv_len = kv_prefix + n_q;
1047    assert!(k_cache.len() >= kv_len * kv_stride);
1048    assert!(v_cache.len() >= kv_len * kv_stride);
1049
1050    let group_size = n_heads / n_kv_heads.max(1);
1051    let scale = 1.0 / (head_dim as f32).sqrt();
1052    let mut out = vec![0f32; n_q * q_stride];
1053
1054    // Blocked three-pass attention in llama.cpp's CPU shape: `KQ` as one
1055    // real `ggml_mul_mat`, one vectorized softmax over each score row,
1056    // then `KQV` as a second `ggml_mul_mat`. Tasks own a block of
1057    // queries for one head, so the K/V rows they stream stay hot across
1058    // the block; the raw pointer only bridges Send/Sync -- tasks write
1059    // disjoint `(query, head)` slices.
1060    struct OutPtr(*mut f32);
1061    unsafe impl Send for OutPtr {}
1062    unsafe impl Sync for OutPtr {}
1063    impl OutPtr {
1064        /// Safety: no two concurrent callers may overlap `[off, off+len)`.
1065        #[inline]
1066        unsafe fn write(&self, off: usize, src: &[f32]) {
1067            std::ptr::copy_nonoverlapping(src.as_ptr(), self.0.add(off), src.len());
1068        }
1069    }
1070
1071    /// Per-worker scratch, reused across every task a Rayon worker
1072    /// runs: a packed Q tile, the `[Q_BLOCK, span]` score tile and the
1073    /// `[Q_BLOCK, head_dim]` output accumulator. Allocating these per
1074    /// task cost a malloc/free pair and a memset per `(query-block,
1075    /// head)`, of which a `pp512` layer has `n_q/8 * n_heads`.
1076    #[derive(Default)]
1077    struct Scratch {
1078        q_tile: Vec<f32>,
1079        scores: Vec<f32>,
1080        acc: Vec<f32>,
1081    }
1082
1083    const Q_BLOCK: usize = 8;
1084    let n_blocks = n_q.div_ceil(Q_BLOCK);
1085    let out_w = OutPtr(out.as_mut_ptr());
1086    let softcap = attn_softcap.filter(|&c| c > 0.0);
1087
1088    crate::par::indices_init(n_blocks * n_heads, 1, Scratch::default, |scratch, task| {
1089        let Scratch {
1090            q_tile,
1091            scores,
1092            acc,
1093        } = scratch;
1094        let blk = task / n_heads;
1095        let h = task % n_heads;
1096        let kv_h = h / group_size.max(1);
1097        let b_start = blk * Q_BLOCK;
1098        let b_end = (b_start + Q_BLOCK).min(n_q);
1099        let n_b = b_end - b_start;
1100
1101        // The block's visible KV span. `t_hi` is the widest causal
1102        // length in the block; `t_lo` is the earliest position its
1103        // first query can still see under the window.
1104        let t_hi = kv_prefix + b_end;
1105        let t_lo = match window {
1106            Some(w) => (kv_prefix + b_start + 1).saturating_sub(w),
1107            None => 0,
1108        };
1109        let span = t_hi - t_lo;
1110        let kv_off = t_lo * kv_stride + kv_h * head_dim;
1111
1112        // Pack the block's Q rows for this head contiguously. The
1113        // GEMM then reads them with `lda = head_dim` instead of
1114        // `n_heads * head_dim`, which for a 32-head model is the
1115        // difference between one tile living in L1 and touching 32
1116        // cache lines per step.
1117        q_tile.clear();
1118        for b in b_start..b_end {
1119            q_tile.extend_from_slice(&q[b * q_stride + h * head_dim..][..head_dim]);
1120        }
1121
1122        // Pass 1: `scores[n_b, span] = scale * Q_tile * Kᵀ` as a
1123        // register-tiled GEMM, computed over the **full** rectangle
1124        // with no mask. Pass 2 zeroes every entry outside a query's
1125        // visible range before pass 3 reads it, so the masked-out
1126        // corners are dead values, never wrong ones: at most
1127        // `Q_BLOCK-1` extra columns per row (0.7% of a 512-wide
1128        // span) bought in exchange for a dense inner loop.
1129        scores.resize(n_b * span, 0.0);
1130        qk_tile(
1131            q_tile, n_b, head_dim, k_cache, kv_off, kv_stride, span, scale, scores,
1132        );
1133
1134        // Pass 2: softcap, then max-subtract softmax, over exactly
1135        // each query's visible range -- and an explicit zero
1136        // everywhere else, which is what turns pass 3 into a dense
1137        // GEMM (llama.cpp reaches the same state by adding a `-INF`
1138        // mask row before `ggml_soft_max_ext`).
1139        let mut norms = [0f32; Q_BLOCK];
1140        for b in b_start..b_end {
1141            let causal_len = kv_prefix + b + 1;
1142            // Same visible range as `causal_gqa_attention_windowed_softcap`
1143            // called with `seq_len = causal_len`.
1144            let t_start = match window {
1145                Some(w) => causal_len.saturating_sub(w),
1146                None => 0,
1147            };
1148            let row = &mut scores[(b - b_start) * span..][..span];
1149            let lo = t_start - t_lo;
1150            let hi = causal_len - t_lo;
1151            row[..lo].fill(0.0);
1152            row[hi..].fill(0.0);
1153            let live = &mut row[lo..hi];
1154            if let Some(sc) = softcap {
1155                for s in live.iter_mut() {
1156                    *s = sc * (*s / sc).tanh();
1157                }
1158            }
1159            norms[b - b_start] = softmax_row_exp_sum(live);
1160        }
1161
1162        // Pass 3: `acc[n_b, head_dim] += P * V`, the second GEMM.
1163        // Zero probabilities contribute `fma(v, 0, acc) == acc`
1164        // exactly, so dropping the mask here is bit-identical to
1165        // skipping those positions.
1166        acc.resize(n_b * head_dim, 0.0);
1167        acc.fill(0.0);
1168        pv_tile(scores, n_b, span, v_cache, kv_off, kv_stride, head_dim, acc);
1169
1170        for b in b_start..b_end {
1171            let out_h = &mut acc[(b - b_start) * head_dim..][..head_dim];
1172            let l = norms[b - b_start];
1173            if l > 0.0 {
1174                scale_inplace(out_h, 1.0 / l);
1175            }
1176            unsafe {
1177                out_w.write(b * q_stride + h * head_dim, out_h);
1178            }
1179        }
1180    });
1181
1182    out
1183}
1184
1185/// In-place row softmax for the blocked prefill kernel: `x[i]` becomes
1186/// `exp(x[i] - max(x))` and the sum of those exponentials is returned,
1187/// so the caller divides once at the end instead of normalising per
1188/// position.
1189///
1190/// This is the third pass of the blocked form, and on small models it
1191/// was the expensive one. `pass 1` and `pass 3` are register-tiled
1192/// GEMMs; this pass was a **scalar `f32::exp` per (query, KV position)**,
1193/// i.e. one libm `expf` call for every score the GEMM had just produced
1194/// four-at-a-time. At `pp512` a single layer of a 32-head model issues
1195/// `512 × 32 × ~256 ≈ 4.2 M` of them.
1196///
1197/// llama.cpp does not pay that: `ggml_vec_soft_max_f32`
1198/// (`ggml/src/ggml-cpu/vec.cpp`) exponentiates a whole row through
1199/// `ggml_v_expf` (`ggml/src/ggml-cpu/vec.h`), which is ARM's
1200/// optimized-routines `expf` rewritten over a vector register. This is
1201/// that same routine; see [`expf_neon`] for the derivation.
1202///
1203/// **This changes CPU prefill numerics**, and deliberately: the
1204/// polynomial is not libm's `expf` to the last bit, and the vector
1205/// accumulator reassociates the sum. On a near-tie that is enough to
1206/// move a greedy argmax, so a CPU generation is not token-identical to
1207/// what the scalar form produced. It is not *less* accurate -- the
1208/// probabilities land at the same handful of ulps and the normaliser
1209/// lands closer to the truth, which
1210/// `the_vectorised_softmax_is_no_less_accurate_than_the_scalar_one`
1211/// measures against an `f64` reference.
1212///
1213/// The reduction order of the max is irrelevant (max is associative and
1214/// the scores are finite). Both the kernel and the row-at-a-time
1215/// reference it is pinned against call this one function, which is what
1216/// keeps `position_outer_prefill_is_bit_identical_to_the_query_outer_form`
1217/// an equality test rather than a tolerance;
1218/// `vectorised_softmax_row_matches_the_scalar_libm_form` is what checks
1219/// this function against `f32::exp` itself.
1220#[inline]
1221fn softmax_row_exp_sum(x: &mut [f32]) -> f32 {
1222    if x.is_empty() {
1223        // A zero-width visible range (`window == 0`) leaves the caller's
1224        // accumulator at zero and skips the normalisation, which is what
1225        // the scalar form did too: `l` never left `0.0`.
1226        return 0.0;
1227    }
1228    #[cfg(target_arch = "aarch64")]
1229    {
1230        if std::arch::is_aarch64_feature_detected!("neon") {
1231            return unsafe { softmax_row_exp_sum_neon(x) };
1232        }
1233    }
1234    #[cfg(target_arch = "x86_64")]
1235    {
1236        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
1237            return unsafe { softmax_row_exp_sum_avx2(x) };
1238        }
1239    }
1240    softmax_row_exp_sum_scalar(x)
1241}
1242
1243/// Scalar `softmax_row_exp_sum` for hosts with neither NEON nor AVX2 --
1244/// and the shape the SIMD arms are tested against.
1245fn softmax_row_exp_sum_scalar(x: &mut [f32]) -> f32 {
1246    let m = x.iter().fold(f32::NEG_INFINITY, |a, &s| a.max(s));
1247    let mut l = 0f32;
1248    for s in x.iter_mut() {
1249        *s = (*s - m).exp();
1250        l += *s;
1251    }
1252    l
1253}
1254
1255/// `exp(x)` for four lanes at once: ARM optimized-routines' `expf` in
1256/// the shape llama.cpp vendors as `ggml_v_expf`
1257/// (`ggml/src/ggml-cpu/vec.h`, the `__ARM_NEON` arm).
1258///
1259/// `z = fma(x, log2(e), 0x1.8p23)` rounds `x·log2(e)` to an integer `n`
1260/// by the round-to-nearest of the add itself, and leaves that integer in
1261/// the low mantissa bits of `z`, so `bits(z) << 23` is exactly the
1262/// exponent field of `2^n` -- one shift instead of a conversion and a
1263/// scalb. `b = x - n·ln2_hi - n·ln2_lo` is the reduced argument in
1264/// `[-ln2/2, ln2/2]` (split so the product is exact in `f32`), and the
1265/// degree-5 minimax polynomial evaluates `e^b - 1` there. The result is
1266/// `2^n · (1 + j)`, accurate to under an ulp.
1267///
1268/// **The overflow branch of the original is dropped, and the clamp is
1269/// what makes that sound.** llama.cpp keeps a slow path for `|n| > 126`
1270/// because `ggml_v_expf` is a general `expf`. Here every argument is
1271/// `score - row_max`, hence `<= 0`, and `exp` of anything below about
1272/// `-87.3` is already smaller than the smallest normal `f32` -- so
1273/// clamping the input at `-87` changes no representable output (the row
1274/// max itself contributes `exp(0) == 1.0` exactly, so a clamped term is
1275/// at most `1.6e-38` of the sum) while pinning `n` to `[-125.5, 0]`,
1276/// where the fast path is the only path.
1277#[cfg(target_arch = "aarch64")]
1278#[target_feature(enable = "neon")]
1279#[inline]
1280unsafe fn expf_neon(x: std::arch::aarch64::float32x4_t) -> std::arch::aarch64::float32x4_t {
1281    use std::arch::aarch64::*;
1282    let x = vmaxq_f32(x, vdupq_n_f32(EXP_MIN_ARG));
1283    let r = vdupq_n_f32(EXP_SHIFT);
1284    let z = vfmaq_f32(r, x, vdupq_n_f32(EXP_LOG2E));
1285    let n = vsubq_f32(z, r);
1286    // `b = x - n*ln2_hi - n*ln2_lo`; `vfmsq_f32(a, b, c) == a - b*c`.
1287    let b = vfmsq_f32(
1288        vfmsq_f32(x, n, vdupq_n_f32(EXP_LN2_HI)),
1289        n,
1290        vdupq_n_f32(EXP_LN2_LO),
1291    );
1292    // `2^n`, built by dropping `n` into the exponent field. The add
1293    // wraps for negative `n`, which is exactly the intended borrow.
1294    let e = vshlq_n_u32::<23>(vreinterpretq_u32_f32(z));
1295    let k = vreinterpretq_f32_u32(vaddq_u32(e, vreinterpretq_u32_f32(vdupq_n_f32(1.0))));
1296    let u = vmulq_f32(b, b);
1297    let j = vfmaq_f32(
1298        vmulq_f32(vdupq_n_f32(EXP_C0), b),
1299        vfmaq_f32(
1300            vfmaq_f32(vdupq_n_f32(EXP_C1), vdupq_n_f32(EXP_C2), b),
1301            vfmaq_f32(vdupq_n_f32(EXP_C3), vdupq_n_f32(EXP_C4), b),
1302            u,
1303        ),
1304        u,
1305    );
1306    vfmaq_f32(k, j, k)
1307}
1308
1309/// AVX2 sibling of [`expf_neon`]: same constants, same polynomial, same
1310/// clamp, eight lanes (`ggml_v_expf`'s `__AVX2__ && __FMA__` arm).
1311#[cfg(target_arch = "x86_64")]
1312#[target_feature(enable = "avx2,fma")]
1313#[inline]
1314unsafe fn expf_avx2(x: std::arch::x86_64::__m256) -> std::arch::x86_64::__m256 {
1315    use std::arch::x86_64::*;
1316    let x = _mm256_max_ps(x, _mm256_set1_ps(EXP_MIN_ARG));
1317    let r = _mm256_set1_ps(EXP_SHIFT);
1318    let z = _mm256_fmadd_ps(x, _mm256_set1_ps(EXP_LOG2E), r);
1319    let n = _mm256_sub_ps(z, r);
1320    let b = _mm256_fnmadd_ps(
1321        n,
1322        _mm256_set1_ps(EXP_LN2_LO),
1323        _mm256_fnmadd_ps(n, _mm256_set1_ps(EXP_LN2_HI), x),
1324    );
1325    let e = _mm256_slli_epi32::<23>(_mm256_castps_si256(z));
1326    let k = _mm256_castsi256_ps(_mm256_add_epi32(
1327        e,
1328        _mm256_castps_si256(_mm256_set1_ps(1.0)),
1329    ));
1330    let u = _mm256_mul_ps(b, b);
1331    let j = _mm256_fmadd_ps(
1332        _mm256_fmadd_ps(
1333            _mm256_fmadd_ps(_mm256_set1_ps(EXP_C4), b, _mm256_set1_ps(EXP_C3)),
1334            u,
1335            _mm256_fmadd_ps(_mm256_set1_ps(EXP_C2), b, _mm256_set1_ps(EXP_C1)),
1336        ),
1337        u,
1338        _mm256_mul_ps(_mm256_set1_ps(EXP_C0), b),
1339    );
1340    _mm256_fmadd_ps(j, k, k)
1341}
1342
1343// The `ggml_v_expf` constants, shared by both vector arms and used by
1344// neither scalar path -- gated so a host with no SIMD arm at all still
1345// compiles clean under `-D warnings`.
1346#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
1347mod exp_consts {
1348    // Shared with `matmul`, which had a byte-identical copy of these
1349    // nine. Only EXP_MIN_ARG below is ours: a softmax argument is
1350    // always <= 0, so this clamps below only, where `matmul` must also
1351    // select zero above because its argument is an unbounded
1352    // denominator.
1353    pub use crate::vexp::*;
1354
1355    /// Below this the exponential is smaller than `f32::MIN_POSITIVE`, so
1356    /// clamping here costs nothing and keeps `n` inside the fast path.
1357    pub const EXP_MIN_ARG: f32 = -87.0;
1358}
1359#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
1360use exp_consts::*;
1361
1362#[cfg(target_arch = "aarch64")]
1363#[target_feature(enable = "neon")]
1364unsafe fn softmax_row_exp_sum_neon(x: &mut [f32]) -> f32 {
1365    use std::arch::aarch64::*;
1366    let n = x.len();
1367    let p = x.as_mut_ptr();
1368    let nv = n & !3;
1369
1370    let mut mv = vdupq_n_f32(f32::NEG_INFINITY);
1371    let mut i = 0;
1372    while i < nv {
1373        mv = vmaxq_f32(mv, vld1q_f32(p.add(i)));
1374        i += 4;
1375    }
1376    let mut m = if nv == 0 {
1377        f32::NEG_INFINITY
1378    } else {
1379        vmaxvq_f32(mv)
1380    };
1381    for j in nv..n {
1382        m = m.max(*p.add(j));
1383    }
1384
1385    let mvec = vdupq_n_f32(m);
1386    let mut sv = vdupq_n_f32(0.0);
1387    let mut i = 0;
1388    while i < nv {
1389        let e = expf_neon(vsubq_f32(vld1q_f32(p.add(i)), mvec));
1390        vst1q_f32(p.add(i), e);
1391        sv = vaddq_f32(sv, e);
1392        i += 4;
1393    }
1394    let mut l = vaddvq_f32(sv);
1395    if nv < n {
1396        // The tail goes through the same approximation rather than
1397        // `f32::exp`, so a row's values do not change character at the
1398        // width boundary. Padding lanes hold `0.0`; they are exponentiated
1399        // and then simply not read.
1400        let mut buf = [0f32; 4];
1401        for (j, slot) in (nv..n).zip(buf.iter_mut()) {
1402            *slot = *p.add(j) - m;
1403        }
1404        vst1q_f32(buf.as_mut_ptr(), expf_neon(vld1q_f32(buf.as_ptr())));
1405        for (j, &e) in (nv..n).zip(buf.iter()) {
1406            *p.add(j) = e;
1407            l += e;
1408        }
1409    }
1410    l
1411}
1412
1413#[cfg(target_arch = "x86_64")]
1414#[target_feature(enable = "avx2,fma")]
1415unsafe fn softmax_row_exp_sum_avx2(x: &mut [f32]) -> f32 {
1416    use std::arch::x86_64::*;
1417    let n = x.len();
1418    let p = x.as_mut_ptr();
1419    let nv = n & !7;
1420
1421    let mut mv = _mm256_set1_ps(f32::NEG_INFINITY);
1422    let mut i = 0;
1423    while i < nv {
1424        mv = _mm256_max_ps(mv, _mm256_loadu_ps(p.add(i)));
1425        i += 8;
1426    }
1427    let mut m = if nv == 0 {
1428        f32::NEG_INFINITY
1429    } else {
1430        let mut lanes = [0f32; 8];
1431        _mm256_storeu_ps(lanes.as_mut_ptr(), mv);
1432        lanes.iter().fold(f32::NEG_INFINITY, |a, &s| a.max(s))
1433    };
1434    for j in nv..n {
1435        m = m.max(*p.add(j));
1436    }
1437
1438    let mvec = _mm256_set1_ps(m);
1439    let mut sv = _mm256_setzero_ps();
1440    let mut i = 0;
1441    while i < nv {
1442        let e = expf_avx2(_mm256_sub_ps(_mm256_loadu_ps(p.add(i)), mvec));
1443        _mm256_storeu_ps(p.add(i), e);
1444        sv = _mm256_add_ps(sv, e);
1445        i += 8;
1446    }
1447    let mut l = hsum256_ps(sv);
1448    if nv < n {
1449        let mut buf = [0f32; 8];
1450        for (j, slot) in (nv..n).zip(buf.iter_mut()) {
1451            *slot = *p.add(j) - m;
1452        }
1453        _mm256_storeu_ps(buf.as_mut_ptr(), expf_avx2(_mm256_loadu_ps(buf.as_ptr())));
1454        for (j, &e) in (nv..n).zip(buf.iter()) {
1455            *p.add(j) = e;
1456            l += e;
1457        }
1458    }
1459    l
1460}
1461
1462/// `scores[b][t] = scale · Σ_d q_tile[b][d]·k[t][d]` for one query block
1463/// against one head's K rows: the `KQ` matmul that llama.cpp expresses as
1464/// a plain `ggml_mul_mat` and dispatches into tinyBLAS.
1465///
1466/// `q_tile` is packed contiguous `[n_b, head_dim]`; K row `t` lives at
1467/// `k[k_off + t*k_stride ..][..head_dim]`, so the caller's
1468/// `[pos, kv_head, dim]` cache needs no repack.
1469///
1470/// The port is of tinyBLAS's `gemm_bloc_<RM>x<RN>`
1471/// (`ggml/src/ggml-cpu/llamafile/sgemm.cpp`): hold an `RM × RN` register
1472/// tile of vector accumulators, load `RM` A-vectors and `RN` B-vectors per
1473/// step along `k`, and horizontally sum once at the end. What it replaces
1474/// was a `dot_f32` per `(query, KV position)`, i.e. a whole K row re-read
1475/// per query -- two loads for every FMA. The 4×4 NEON tile issues eight
1476/// loads for sixteen FMAs, and each K row is read once per query block
1477/// rather than once per query.
1478///
1479/// The reduction order is deliberately the same as [`dot_f32`]'s on each
1480/// backend (4-wide + `vaddvq` under NEON, 8-wide + the same horizontal sum
1481/// under AVX2, scalar tail after the horizontal sum), so this is
1482/// bit-identical to the row-at-a-time loop rather than merely close.
1483// Kept out of line: one call per `(query-block, head)` costs nothing
1484// against a 512x64x64 tile of FMAs, and it keeps this kernel a named
1485// symbol in a `sample` profile instead of vanishing into the Rayon
1486// closure -- which is how its cost was found in the first place.
1487#[inline(never)]
1488#[allow(clippy::too_many_arguments)]
1489fn qk_tile(
1490    q_tile: &[f32],
1491    n_b: usize,
1492    head_dim: usize,
1493    k: &[f32],
1494    k_off: usize,
1495    k_stride: usize,
1496    span: usize,
1497    scale: f32,
1498    scores: &mut [f32],
1499) {
1500    debug_assert_eq!(q_tile.len(), n_b * head_dim);
1501    debug_assert_eq!(scores.len(), n_b * span);
1502    #[cfg(target_arch = "aarch64")]
1503    {
1504        if std::arch::is_aarch64_feature_detected!("neon") {
1505            unsafe {
1506                qk_tile_neon(
1507                    q_tile, n_b, head_dim, k, k_off, k_stride, span, scale, scores,
1508                )
1509            };
1510            return;
1511        }
1512    }
1513    #[cfg(target_arch = "x86_64")]
1514    {
1515        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
1516            unsafe {
1517                qk_tile_avx2(
1518                    q_tile, n_b, head_dim, k, k_off, k_stride, span, scale, scores,
1519                )
1520            };
1521            return;
1522        }
1523    }
1524    qk_rows(
1525        q_tile,
1526        head_dim,
1527        k,
1528        k_off,
1529        k_stride,
1530        span,
1531        scale,
1532        scores,
1533        0..n_b,
1534        0..span,
1535    );
1536}
1537
1538/// Row-at-a-time `Q·Kᵀ` over a sub-rectangle of the score tile: the
1539/// edges the register tile does not cover, and the whole tile on hosts
1540/// with neither NEON nor AVX2.
1541#[allow(clippy::too_many_arguments)]
1542fn qk_rows(
1543    q_tile: &[f32],
1544    head_dim: usize,
1545    k: &[f32],
1546    k_off: usize,
1547    k_stride: usize,
1548    span: usize,
1549    scale: f32,
1550    scores: &mut [f32],
1551    rows: std::ops::Range<usize>,
1552    cols: std::ops::Range<usize>,
1553) {
1554    for b in rows {
1555        let q_b = &q_tile[b * head_dim..][..head_dim];
1556        for t in cols.clone() {
1557            let k_t = &k[k_off + t * k_stride..][..head_dim];
1558            scores[b * span + t] = dot_f32(q_b, k_t) * scale;
1559        }
1560    }
1561}
1562
1563#[cfg(target_arch = "aarch64")]
1564#[target_feature(enable = "neon")]
1565#[allow(clippy::too_many_arguments)]
1566unsafe fn qk_tile_neon(
1567    q_tile: &[f32],
1568    n_b: usize,
1569    head_dim: usize,
1570    k: &[f32],
1571    k_off: usize,
1572    k_stride: usize,
1573    span: usize,
1574    scale: f32,
1575    scores: &mut [f32],
1576) {
1577    use std::arch::aarch64::*;
1578    let qp = q_tile.as_ptr();
1579    let kp = k.as_ptr().add(k_off);
1580    let sp = scores.as_mut_ptr();
1581    // Rows/columns the 4×4 tile covers, and the 4-wide part of `head_dim`
1582    // -- the same boundary `dot_f32_neon` uses, which is what keeps the
1583    // scalar leftovers bit-identical.
1584    let bt = n_b & !3;
1585    let tt = span & !3;
1586    let dv = head_dim & !3;
1587
1588    // KV position outer, query block inner: the four K rows of a tile are
1589    // loaded once and reused by every query tile, so one pass over this
1590    // head's K slab serves the whole query block.
1591    let mut t0 = 0;
1592    while t0 < tt {
1593        let k0 = kp.add(t0 * k_stride);
1594        let k1 = k0.add(k_stride);
1595        let k2 = k1.add(k_stride);
1596        let k3 = k2.add(k_stride);
1597        let mut b0 = 0;
1598        while b0 < bt {
1599            let a0 = qp.add(b0 * head_dim);
1600            let a1 = a0.add(head_dim);
1601            let a2 = a1.add(head_dim);
1602            let a3 = a2.add(head_dim);
1603            let z = vdupq_n_f32(0.0);
1604            // `cIJ` accumulates query `b0+I` against key `t0+J`.
1605            let (mut c00, mut c01, mut c02, mut c03) = (z, z, z, z);
1606            let (mut c10, mut c11, mut c12, mut c13) = (z, z, z, z);
1607            let (mut c20, mut c21, mut c22, mut c23) = (z, z, z, z);
1608            let (mut c30, mut c31, mut c32, mut c33) = (z, z, z, z);
1609            let mut d = 0;
1610            while d < dv {
1611                let av0 = vld1q_f32(a0.add(d));
1612                let av1 = vld1q_f32(a1.add(d));
1613                let av2 = vld1q_f32(a2.add(d));
1614                let av3 = vld1q_f32(a3.add(d));
1615                let kv0 = vld1q_f32(k0.add(d));
1616                c00 = vfmaq_f32(c00, av0, kv0);
1617                c10 = vfmaq_f32(c10, av1, kv0);
1618                c20 = vfmaq_f32(c20, av2, kv0);
1619                c30 = vfmaq_f32(c30, av3, kv0);
1620                let kv1 = vld1q_f32(k1.add(d));
1621                c01 = vfmaq_f32(c01, av0, kv1);
1622                c11 = vfmaq_f32(c11, av1, kv1);
1623                c21 = vfmaq_f32(c21, av2, kv1);
1624                c31 = vfmaq_f32(c31, av3, kv1);
1625                let kv2 = vld1q_f32(k2.add(d));
1626                c02 = vfmaq_f32(c02, av0, kv2);
1627                c12 = vfmaq_f32(c12, av1, kv2);
1628                c22 = vfmaq_f32(c22, av2, kv2);
1629                c32 = vfmaq_f32(c32, av3, kv2);
1630                let kv3 = vld1q_f32(k3.add(d));
1631                c03 = vfmaq_f32(c03, av0, kv3);
1632                c13 = vfmaq_f32(c13, av1, kv3);
1633                c23 = vfmaq_f32(c23, av2, kv3);
1634                c33 = vfmaq_f32(c33, av3, kv3);
1635                d += 4;
1636            }
1637            let mut r = [
1638                [
1639                    vaddvq_f32(c00),
1640                    vaddvq_f32(c01),
1641                    vaddvq_f32(c02),
1642                    vaddvq_f32(c03),
1643                ],
1644                [
1645                    vaddvq_f32(c10),
1646                    vaddvq_f32(c11),
1647                    vaddvq_f32(c12),
1648                    vaddvq_f32(c13),
1649                ],
1650                [
1651                    vaddvq_f32(c20),
1652                    vaddvq_f32(c21),
1653                    vaddvq_f32(c22),
1654                    vaddvq_f32(c23),
1655                ],
1656                [
1657                    vaddvq_f32(c30),
1658                    vaddvq_f32(c31),
1659                    vaddvq_f32(c32),
1660                    vaddvq_f32(c33),
1661                ],
1662            ];
1663            // Leftover dims after the horizontal sum, exactly where
1664            // `dot_f32_neon` adds them.
1665            let arow = [a0, a1, a2, a3];
1666            let krow = [k0, k1, k2, k3];
1667            for d in dv..head_dim {
1668                for (i, ai) in arow.iter().enumerate() {
1669                    let av = *ai.add(d);
1670                    for (j, kj) in krow.iter().enumerate() {
1671                        r[i][j] += av * *kj.add(d);
1672                    }
1673                }
1674            }
1675            for (i, ri) in r.iter().enumerate() {
1676                for (j, v) in ri.iter().enumerate() {
1677                    *sp.add((b0 + i) * span + t0 + j) = v * scale;
1678                }
1679            }
1680            b0 += 4;
1681        }
1682        t0 += 4;
1683    }
1684    qk_rows(
1685        q_tile,
1686        head_dim,
1687        k,
1688        k_off,
1689        k_stride,
1690        span,
1691        scale,
1692        scores,
1693        0..bt,
1694        tt..span,
1695    );
1696    qk_rows(
1697        q_tile,
1698        head_dim,
1699        k,
1700        k_off,
1701        k_stride,
1702        span,
1703        scale,
1704        scores,
1705        bt..n_b,
1706        0..span,
1707    );
1708}
1709
1710/// AVX2 sibling of [`qk_tile_neon`]. The register file is half as wide
1711/// (16 YMM), so the tile is 4 queries × 2 keys -- 8 accumulators plus 4
1712/// A-vectors and a B-vector -- instead of 4×4.
1713#[cfg(target_arch = "x86_64")]
1714#[target_feature(enable = "avx2,fma")]
1715#[allow(clippy::too_many_arguments)]
1716unsafe fn qk_tile_avx2(
1717    q_tile: &[f32],
1718    n_b: usize,
1719    head_dim: usize,
1720    k: &[f32],
1721    k_off: usize,
1722    k_stride: usize,
1723    span: usize,
1724    scale: f32,
1725    scores: &mut [f32],
1726) {
1727    use std::arch::x86_64::*;
1728    let qp = q_tile.as_ptr();
1729    let kp = k.as_ptr().add(k_off);
1730    let sp = scores.as_mut_ptr();
1731    let bt = n_b & !3;
1732    let tt = span & !1;
1733    let dv = head_dim & !7;
1734
1735    let mut t0 = 0;
1736    while t0 < tt {
1737        let k0 = kp.add(t0 * k_stride);
1738        let k1 = k0.add(k_stride);
1739        let mut b0 = 0;
1740        while b0 < bt {
1741            let a0 = qp.add(b0 * head_dim);
1742            let a1 = a0.add(head_dim);
1743            let a2 = a1.add(head_dim);
1744            let a3 = a2.add(head_dim);
1745            let z = _mm256_setzero_ps();
1746            let (mut c00, mut c01) = (z, z);
1747            let (mut c10, mut c11) = (z, z);
1748            let (mut c20, mut c21) = (z, z);
1749            let (mut c30, mut c31) = (z, z);
1750            let mut d = 0;
1751            while d < dv {
1752                let av0 = _mm256_loadu_ps(a0.add(d));
1753                let av1 = _mm256_loadu_ps(a1.add(d));
1754                let av2 = _mm256_loadu_ps(a2.add(d));
1755                let av3 = _mm256_loadu_ps(a3.add(d));
1756                let kv0 = _mm256_loadu_ps(k0.add(d));
1757                c00 = _mm256_fmadd_ps(av0, kv0, c00);
1758                c10 = _mm256_fmadd_ps(av1, kv0, c10);
1759                c20 = _mm256_fmadd_ps(av2, kv0, c20);
1760                c30 = _mm256_fmadd_ps(av3, kv0, c30);
1761                let kv1 = _mm256_loadu_ps(k1.add(d));
1762                c01 = _mm256_fmadd_ps(av0, kv1, c01);
1763                c11 = _mm256_fmadd_ps(av1, kv1, c11);
1764                c21 = _mm256_fmadd_ps(av2, kv1, c21);
1765                c31 = _mm256_fmadd_ps(av3, kv1, c31);
1766                d += 8;
1767            }
1768            let mut r = [
1769                [hsum256_ps(c00), hsum256_ps(c01)],
1770                [hsum256_ps(c10), hsum256_ps(c11)],
1771                [hsum256_ps(c20), hsum256_ps(c21)],
1772                [hsum256_ps(c30), hsum256_ps(c31)],
1773            ];
1774            let arow = [a0, a1, a2, a3];
1775            let krow = [k0, k1];
1776            for d in dv..head_dim {
1777                for (i, ai) in arow.iter().enumerate() {
1778                    let av = *ai.add(d);
1779                    for (j, kj) in krow.iter().enumerate() {
1780                        r[i][j] += av * *kj.add(d);
1781                    }
1782                }
1783            }
1784            for (i, ri) in r.iter().enumerate() {
1785                for (j, v) in ri.iter().enumerate() {
1786                    *sp.add((b0 + i) * span + t0 + j) = v * scale;
1787                }
1788            }
1789            b0 += 4;
1790        }
1791        t0 += 2;
1792    }
1793    qk_rows(
1794        q_tile,
1795        head_dim,
1796        k,
1797        k_off,
1798        k_stride,
1799        span,
1800        scale,
1801        scores,
1802        0..bt,
1803        tt..span,
1804    );
1805    qk_rows(
1806        q_tile,
1807        head_dim,
1808        k,
1809        k_off,
1810        k_stride,
1811        span,
1812        scale,
1813        scores,
1814        bt..n_b,
1815        0..span,
1816    );
1817}
1818
1819/// `acc[b][d] += Σ_t p[b][t]·v[t][d]` for one query block against one
1820/// head's V rows: the `KQV` matmul, the second of llama.cpp's two
1821/// attention `ggml_mul_mat`s.
1822///
1823/// V row `t` lives at `v[v_off + t*v_stride ..][..head_dim]`. `p` is the
1824/// `[n_b, span]` probability tile pass 2 produced, already zeroed outside
1825/// each query's visible range, so no mask is needed here.
1826///
1827/// Register-tiled the other way round from [`qk_tile`]: the output tile
1828/// (8 queries × 8 dims) lives in the accumulators and `t` is the
1829/// reduction axis, so a V row is loaded once and feeds all eight
1830/// queries. What it replaces was an `axpy` per `(KV position, query)`,
1831/// which re-loaded and re-stored the whole `head_dim`-wide accumulator
1832/// row for every position -- three L1 accesses per FMA against this
1833/// version's ten loads per sixteen vector FMAs.
1834///
1835/// Accumulation order along `t` is unchanged (ascending, one `fma` per
1836/// position), and the vector/scalar boundary matches [`axpy`]'s on each
1837/// backend, so this is bit-identical to the row-at-a-time loop.
1838// Out of line for the same reason as [`qk_tile`].
1839#[inline(never)]
1840#[allow(clippy::too_many_arguments)]
1841fn pv_tile(
1842    p: &[f32],
1843    n_b: usize,
1844    span: usize,
1845    v: &[f32],
1846    v_off: usize,
1847    v_stride: usize,
1848    head_dim: usize,
1849    acc: &mut [f32],
1850) {
1851    debug_assert_eq!(p.len(), n_b * span);
1852    debug_assert_eq!(acc.len(), n_b * head_dim);
1853    #[cfg(target_arch = "aarch64")]
1854    {
1855        if std::arch::is_aarch64_feature_detected!("neon") {
1856            unsafe { pv_tile_neon(p, n_b, span, v, v_off, v_stride, head_dim, acc) };
1857            return;
1858        }
1859    }
1860    #[cfg(target_arch = "x86_64")]
1861    {
1862        if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
1863            unsafe { pv_tile_avx2(p, n_b, span, v, v_off, v_stride, head_dim, acc) };
1864            return;
1865        }
1866    }
1867    pv_rows(p, span, v, v_off, v_stride, head_dim, acc, 0..n_b);
1868}
1869
1870/// Row-at-a-time `P·V` for the query rows the register tile does not
1871/// cover, and for hosts with neither NEON nor AVX2. Zero probabilities
1872/// are skipped rather than accumulated -- `acc + v*0` is exactly `acc`
1873/// for finite `v`, so this is a pure work saving on the masked padding.
1874#[allow(clippy::too_many_arguments)]
1875fn pv_rows(
1876    p: &[f32],
1877    span: usize,
1878    v: &[f32],
1879    v_off: usize,
1880    v_stride: usize,
1881    head_dim: usize,
1882    acc: &mut [f32],
1883    rows: std::ops::Range<usize>,
1884) {
1885    for b in rows {
1886        let out_b = &mut acc[b * head_dim..][..head_dim];
1887        for t in 0..span {
1888            let w = p[b * span + t];
1889            if w == 0.0 {
1890                continue;
1891            }
1892            axpy(out_b, &v[v_off + t * v_stride..][..head_dim], w);
1893        }
1894    }
1895}
1896
1897#[cfg(target_arch = "aarch64")]
1898#[target_feature(enable = "neon")]
1899#[allow(clippy::too_many_arguments)]
1900unsafe fn pv_tile_neon(
1901    p: &[f32],
1902    n_b: usize,
1903    span: usize,
1904    v: &[f32],
1905    v_off: usize,
1906    v_stride: usize,
1907    head_dim: usize,
1908    acc: &mut [f32],
1909) {
1910    use std::arch::aarch64::*;
1911    let vp = v.as_ptr().add(v_off);
1912    let pp = p.as_ptr();
1913    let ap = acc.as_mut_ptr();
1914    let bt = n_b & !7;
1915    let dv = head_dim & !7;
1916    // `axpy_neon` vectorizes up to `head_dim & !3` and goes scalar after;
1917    // matching both boundaries is what makes the leftovers bit-identical.
1918    let dv4 = head_dim & !3;
1919
1920    let mut b0 = 0;
1921    while b0 < bt {
1922        let mut d0 = 0;
1923        while d0 < dv {
1924            let mut c0l = vld1q_f32(ap.add(b0 * head_dim + d0));
1925            let mut c0h = vld1q_f32(ap.add(b0 * head_dim + d0 + 4));
1926            let mut c1l = vld1q_f32(ap.add((b0 + 1) * head_dim + d0));
1927            let mut c1h = vld1q_f32(ap.add((b0 + 1) * head_dim + d0 + 4));
1928            let mut c2l = vld1q_f32(ap.add((b0 + 2) * head_dim + d0));
1929            let mut c2h = vld1q_f32(ap.add((b0 + 2) * head_dim + d0 + 4));
1930            let mut c3l = vld1q_f32(ap.add((b0 + 3) * head_dim + d0));
1931            let mut c3h = vld1q_f32(ap.add((b0 + 3) * head_dim + d0 + 4));
1932            let mut c4l = vld1q_f32(ap.add((b0 + 4) * head_dim + d0));
1933            let mut c4h = vld1q_f32(ap.add((b0 + 4) * head_dim + d0 + 4));
1934            let mut c5l = vld1q_f32(ap.add((b0 + 5) * head_dim + d0));
1935            let mut c5h = vld1q_f32(ap.add((b0 + 5) * head_dim + d0 + 4));
1936            let mut c6l = vld1q_f32(ap.add((b0 + 6) * head_dim + d0));
1937            let mut c6h = vld1q_f32(ap.add((b0 + 6) * head_dim + d0 + 4));
1938            let mut c7l = vld1q_f32(ap.add((b0 + 7) * head_dim + d0));
1939            let mut c7h = vld1q_f32(ap.add((b0 + 7) * head_dim + d0 + 4));
1940            for t in 0..span {
1941                let vr = vp.add(t * v_stride + d0);
1942                let v0 = vld1q_f32(vr);
1943                let v1 = vld1q_f32(vr.add(4));
1944                let s0 = vdupq_n_f32(*pp.add(b0 * span + t));
1945                c0l = vfmaq_f32(c0l, v0, s0);
1946                c0h = vfmaq_f32(c0h, v1, s0);
1947                let s1 = vdupq_n_f32(*pp.add((b0 + 1) * span + t));
1948                c1l = vfmaq_f32(c1l, v0, s1);
1949                c1h = vfmaq_f32(c1h, v1, s1);
1950                let s2 = vdupq_n_f32(*pp.add((b0 + 2) * span + t));
1951                c2l = vfmaq_f32(c2l, v0, s2);
1952                c2h = vfmaq_f32(c2h, v1, s2);
1953                let s3 = vdupq_n_f32(*pp.add((b0 + 3) * span + t));
1954                c3l = vfmaq_f32(c3l, v0, s3);
1955                c3h = vfmaq_f32(c3h, v1, s3);
1956                let s4 = vdupq_n_f32(*pp.add((b0 + 4) * span + t));
1957                c4l = vfmaq_f32(c4l, v0, s4);
1958                c4h = vfmaq_f32(c4h, v1, s4);
1959                let s5 = vdupq_n_f32(*pp.add((b0 + 5) * span + t));
1960                c5l = vfmaq_f32(c5l, v0, s5);
1961                c5h = vfmaq_f32(c5h, v1, s5);
1962                let s6 = vdupq_n_f32(*pp.add((b0 + 6) * span + t));
1963                c6l = vfmaq_f32(c6l, v0, s6);
1964                c6h = vfmaq_f32(c6h, v1, s6);
1965                let s7 = vdupq_n_f32(*pp.add((b0 + 7) * span + t));
1966                c7l = vfmaq_f32(c7l, v0, s7);
1967                c7h = vfmaq_f32(c7h, v1, s7);
1968            }
1969            vst1q_f32(ap.add(b0 * head_dim + d0), c0l);
1970            vst1q_f32(ap.add(b0 * head_dim + d0 + 4), c0h);
1971            vst1q_f32(ap.add((b0 + 1) * head_dim + d0), c1l);
1972            vst1q_f32(ap.add((b0 + 1) * head_dim + d0 + 4), c1h);
1973            vst1q_f32(ap.add((b0 + 2) * head_dim + d0), c2l);
1974            vst1q_f32(ap.add((b0 + 2) * head_dim + d0 + 4), c2h);
1975            vst1q_f32(ap.add((b0 + 3) * head_dim + d0), c3l);
1976            vst1q_f32(ap.add((b0 + 3) * head_dim + d0 + 4), c3h);
1977            vst1q_f32(ap.add((b0 + 4) * head_dim + d0), c4l);
1978            vst1q_f32(ap.add((b0 + 4) * head_dim + d0 + 4), c4h);
1979            vst1q_f32(ap.add((b0 + 5) * head_dim + d0), c5l);
1980            vst1q_f32(ap.add((b0 + 5) * head_dim + d0 + 4), c5h);
1981            vst1q_f32(ap.add((b0 + 6) * head_dim + d0), c6l);
1982            vst1q_f32(ap.add((b0 + 6) * head_dim + d0 + 4), c6h);
1983            vst1q_f32(ap.add((b0 + 7) * head_dim + d0), c7l);
1984            vst1q_f32(ap.add((b0 + 7) * head_dim + d0 + 4), c7h);
1985            d0 += 8;
1986        }
1987        // Leftover dims, still `t`-ascending per `(query, dim)`: fused
1988        // below `head_dim & !3` and plain below `head_dim`, which is
1989        // where `axpy_neon`'s own vector/scalar split falls.
1990        if dv < head_dim {
1991            for t in 0..span {
1992                for i in 0..8 {
1993                    let w = *pp.add((b0 + i) * span + t);
1994                    if w == 0.0 {
1995                        continue;
1996                    }
1997                    let row = ap.add((b0 + i) * head_dim);
1998                    for d in dv..dv4 {
1999                        *row.add(d) = f32::mul_add(w, *vp.add(t * v_stride + d), *row.add(d));
2000                    }
2001                    for d in dv4..head_dim {
2002                        *row.add(d) += w * *vp.add(t * v_stride + d);
2003                    }
2004                }
2005            }
2006        }
2007        b0 += 8;
2008    }
2009    pv_rows(p, span, v, v_off, v_stride, head_dim, acc, bt..n_b);
2010}
2011
2012/// AVX2 sibling of [`pv_tile_neon`]: same 8-query × 8-dim output tile,
2013/// but one YMM accumulator per query instead of two NEON quads.
2014#[cfg(target_arch = "x86_64")]
2015#[target_feature(enable = "avx2,fma")]
2016#[allow(clippy::too_many_arguments)]
2017unsafe fn pv_tile_avx2(
2018    p: &[f32],
2019    n_b: usize,
2020    span: usize,
2021    v: &[f32],
2022    v_off: usize,
2023    v_stride: usize,
2024    head_dim: usize,
2025    acc: &mut [f32],
2026) {
2027    use std::arch::x86_64::*;
2028    let vp = v.as_ptr().add(v_off);
2029    let pp = p.as_ptr();
2030    let ap = acc.as_mut_ptr();
2031    let bt = n_b & !7;
2032    // `axpy_avx2` vectorizes up to `head_dim & !7`, so its scalar tail
2033    // and this kernel's are the same elements.
2034    let dv = head_dim & !7;
2035
2036    let mut b0 = 0;
2037    while b0 < bt {
2038        let mut d0 = 0;
2039        while d0 < dv {
2040            let mut c0 = _mm256_loadu_ps(ap.add(b0 * head_dim + d0));
2041            let mut c1 = _mm256_loadu_ps(ap.add((b0 + 1) * head_dim + d0));
2042            let mut c2 = _mm256_loadu_ps(ap.add((b0 + 2) * head_dim + d0));
2043            let mut c3 = _mm256_loadu_ps(ap.add((b0 + 3) * head_dim + d0));
2044            let mut c4 = _mm256_loadu_ps(ap.add((b0 + 4) * head_dim + d0));
2045            let mut c5 = _mm256_loadu_ps(ap.add((b0 + 5) * head_dim + d0));
2046            let mut c6 = _mm256_loadu_ps(ap.add((b0 + 6) * head_dim + d0));
2047            let mut c7 = _mm256_loadu_ps(ap.add((b0 + 7) * head_dim + d0));
2048            for t in 0..span {
2049                let vv = _mm256_loadu_ps(vp.add(t * v_stride + d0));
2050                c0 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add(b0 * span + t)), c0);
2051                c1 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 1) * span + t)), c1);
2052                c2 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 2) * span + t)), c2);
2053                c3 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 3) * span + t)), c3);
2054                c4 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 4) * span + t)), c4);
2055                c5 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 5) * span + t)), c5);
2056                c6 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 6) * span + t)), c6);
2057                c7 = _mm256_fmadd_ps(vv, _mm256_set1_ps(*pp.add((b0 + 7) * span + t)), c7);
2058            }
2059            _mm256_storeu_ps(ap.add(b0 * head_dim + d0), c0);
2060            _mm256_storeu_ps(ap.add((b0 + 1) * head_dim + d0), c1);
2061            _mm256_storeu_ps(ap.add((b0 + 2) * head_dim + d0), c2);
2062            _mm256_storeu_ps(ap.add((b0 + 3) * head_dim + d0), c3);
2063            _mm256_storeu_ps(ap.add((b0 + 4) * head_dim + d0), c4);
2064            _mm256_storeu_ps(ap.add((b0 + 5) * head_dim + d0), c5);
2065            _mm256_storeu_ps(ap.add((b0 + 6) * head_dim + d0), c6);
2066            _mm256_storeu_ps(ap.add((b0 + 7) * head_dim + d0), c7);
2067            d0 += 8;
2068        }
2069        if dv < head_dim {
2070            for t in 0..span {
2071                for i in 0..8 {
2072                    let w = *pp.add((b0 + i) * span + t);
2073                    if w == 0.0 {
2074                        continue;
2075                    }
2076                    let row = ap.add((b0 + i) * head_dim);
2077                    for d in dv..head_dim {
2078                        *row.add(d) += w * *vp.add(t * v_stride + d);
2079                    }
2080                }
2081            }
2082        }
2083        b0 += 8;
2084    }
2085    pv_rows(p, span, v, v_off, v_stride, head_dim, acc, bt..n_b);
2086}
2087
2088/// Same math as `causal_gqa_attention`, but K/V positions are read
2089/// through a `PagedKvStore` block table instead of one contiguous
2090/// slice: position `t` lives in block `block_table[t / block_size]`
2091/// at offset `t % block_size`, so blocks need not be physically
2092/// adjacent or in order. Must match `causal_gqa_attention` given the
2093/// same logical K/V contents (float noise only) — the block table is a
2094/// storage-layout detail, not a math change.
2095pub fn causal_gqa_attention_paged(
2096    q: &[f32],
2097    store: &PagedKvStore,
2098    block_table: &[usize],
2099    n_heads: usize,
2100    n_kv_heads: usize,
2101    head_dim: usize,
2102    seq_len: usize,
2103) -> Vec<f32> {
2104    assert_eq!(q.len(), n_heads * head_dim);
2105    let block_size = store.block_size();
2106    assert!(
2107        block_table.len() * block_size >= seq_len,
2108        "block table too short for seq_len"
2109    );
2110
2111    let group_size = n_heads / n_kv_heads.max(1);
2112    let scale = 1.0 / (head_dim as f32).sqrt();
2113    let mut out = vec![0f32; n_heads * head_dim];
2114
2115    for h in 0..n_heads {
2116        let kv_h = h / group_size.max(1);
2117        let q_h = &q[h * head_dim..(h + 1) * head_dim];
2118        let out_h = &mut out[h * head_dim..(h + 1) * head_dim];
2119        online_attn_accumulate(q_h, scale, head_dim, out_h, None, None, |visit| {
2120            for t in 0..seq_len {
2121                let block_id = block_table[t / block_size];
2122                let offset = t % block_size;
2123                let k_row = store.k_row(block_id, offset);
2124                let v_row = store.v_row(block_id, offset);
2125                let k_t = &k_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2126                let v_t = &v_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2127                visit(k_t, v_t);
2128            }
2129        });
2130    }
2131
2132    out
2133}
2134
2135/// [`causal_gqa_attention_paged`] with per-head attention sinks and an
2136/// optional sliding window: the paged twin of
2137/// [`causal_gqa_attention_sinks`].
2138///
2139/// # Why this had to exist before the paged path could serve anything
2140///
2141/// `causal_gqa_attention_paged` had neither term, and
2142/// `Decoder::forward_token_paged` therefore refused gpt-oss with an
2143/// assert rather than answer it differently from the contiguous path.
2144/// That assert was the right call and a dead end: a sliding-window or
2145/// sink-carrying model could never move onto paged KV, and paged KV is
2146/// what a radix prefix cache hands back page indices for. So this is a
2147/// correctness item before it is a caching one.
2148///
2149/// # Bit-identity is by construction, not by tolerance
2150///
2151/// Both this and the contiguous kernel funnel the same `(k, v)` rows,
2152/// in the same order, through the same [`online_attn_accumulate`] with
2153/// the same scale and the same sink. Nothing is re-associated and no
2154/// sum is reordered, so the results are bit-identical rather than
2155/// close -- which is the only useful bar here, since the whole point is
2156/// that moving a model onto paged KV must not change its distribution.
2157/// The tests assert exact equality.
2158///
2159/// `sinks` is `None` for a model that ships none, which is the ordinary
2160/// case; `window` is `Some(w)` for a sliding-window layer and `None`
2161/// for full causal. `attn_softcap` is carried too, so this one entry
2162/// point can mirror every arm of the contiguous dispatch: a softcapped
2163/// model moved onto paged KV without it would differ silently, which is
2164/// the same class of bug this function exists to close.
2165#[allow(clippy::too_many_arguments)]
2166pub fn causal_gqa_attention_paged_sinks(
2167    q: &[f32],
2168    store: &PagedKvStore,
2169    block_table: &[usize],
2170    n_heads: usize,
2171    n_kv_heads: usize,
2172    head_dim: usize,
2173    seq_len: usize,
2174    window: Option<usize>,
2175    sinks: Option<&[f32]>,
2176    attn_softcap: Option<f32>,
2177) -> Vec<f32> {
2178    assert_eq!(q.len(), n_heads * head_dim);
2179    let block_size = store.block_size();
2180    assert!(
2181        block_table.len() * block_size >= seq_len,
2182        "block table too short for seq_len"
2183    );
2184    if let Some(sinks) = sinks {
2185        assert_eq!(
2186            sinks.len(),
2187            n_heads,
2188            "attention sinks are per query head (llama.cpp `attn_sinks` is {{n_head}})"
2189        );
2190    }
2191
2192    let group_size = n_heads / n_kv_heads.max(1);
2193    let scale = 1.0 / (head_dim as f32).sqrt();
2194    let mut out = vec![0f32; n_heads * head_dim];
2195    // The query is the last cached position; a windowed layer sees only
2196    // the most recent `window` positions including its own. Identical
2197    // to the contiguous kernel's `start`, deliberately: a different
2198    // rounding here would silently shift which token a window drops.
2199    let start = match window {
2200        Some(w) => {
2201            assert!(w > 0, "window must be positive");
2202            seq_len.saturating_sub(w)
2203        }
2204        None => 0,
2205    };
2206
2207    for h in 0..n_heads {
2208        let kv_h = h / group_size.max(1);
2209        let q_h = &q[h * head_dim..(h + 1) * head_dim];
2210        let sink = sinks.map(|s| s[h]);
2211        let out_h = &mut out[h * head_dim..(h + 1) * head_dim];
2212        online_attn_accumulate(q_h, scale, head_dim, out_h, attn_softcap, sink, |visit| {
2213            for t in start..seq_len {
2214                let block_id = block_table[t / block_size];
2215                let offset = t % block_size;
2216                let k_row = store.k_row(block_id, offset);
2217                let v_row = store.v_row(block_id, offset);
2218                let k_t = &k_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2219                let v_t = &v_row[kv_h * head_dim..(kv_h + 1) * head_dim];
2220                visit(k_t, v_t);
2221            }
2222        });
2223    }
2224
2225    out
2226}
2227
2228/// Single-token causal attention for DeepSeek/Kimi-style Multi-head
2229/// Latent Attention (MLA): every query head has its own key/value (no
2230/// GQA-style grouping -- verified directly against Kimi K3's real
2231/// `KimiMLAAttention.forward`, where `kv_b_proj` expands to the full
2232/// `num_heads` count and the `num_key_value_heads`/`num_key_value_groups`
2233/// fields computed in `__init__` go unused), but the key/query head
2234/// dimension (`qk_head_dim` = `qk_nope_head_dim + qk_rope_head_dim`) can
2235/// differ from the value head dimension (`v_head_dim`) -- unlike
2236/// `causal_gqa_attention`, which assumes one shared `head_dim` for both.
2237///
2238/// `q` is [n_heads, qk_head_dim]; `k_cache` is [seq_len, n_heads,
2239/// qk_head_dim]; `v_cache` is [seq_len, n_heads, v_head_dim]. Returns
2240/// [n_heads, v_head_dim].
2241pub fn causal_mla_attention(
2242    q: &[f32],
2243    k_cache: &[f32],
2244    v_cache: &[f32],
2245    n_heads: usize,
2246    qk_head_dim: usize,
2247    v_head_dim: usize,
2248    seq_len: usize,
2249) -> Vec<f32> {
2250    mla_attention_inner(
2251        q,
2252        k_cache,
2253        v_cache,
2254        n_heads,
2255        qk_head_dim,
2256        v_head_dim,
2257        seq_len,
2258        None,
2259        None,
2260    )
2261}
2262
2263/// The one MLA attention body, shared by the dense, sparse and
2264/// sink-carrying entry points.
2265///
2266/// `visible` restricts which key positions participate (`None` is every
2267/// position through `seq_len`); `sinks` is one learned logit per query
2268/// head. Sharing the body is deliberate rather than tidy: the four
2269/// public forms differ only in those two options, and a second copy of
2270/// the softmax is how one of them quietly stops matching the others.
2271///
2272/// A sink joins the softmax denominator with a **zero** value vector,
2273/// so it takes probability mass away from the real keys without
2274/// contributing to the output -- the same semantics as
2275/// [`causal_gqa_attention_sinks`], and for the same reason: it lets a
2276/// head decline to attend to anything rather than being forced to
2277/// spread a full unit of weight over keys it does not want. The sink
2278/// logit is **not** scaled by `1/sqrt(qk_head_dim)`; it is a learned
2279/// logit already in score space.
2280///
2281/// A head whose sink dominates gets an output near zero, which is the
2282/// intended behaviour and not a bug to guard against -- clamping it
2283/// would remove the only thing the sink is for.
2284#[allow(clippy::too_many_arguments)]
2285fn mla_attention_inner(
2286    q: &[f32],
2287    k_cache: &[f32],
2288    v_cache: &[f32],
2289    n_heads: usize,
2290    qk_head_dim: usize,
2291    v_head_dim: usize,
2292    seq_len: usize,
2293    visible: Option<&[usize]>,
2294    sinks: Option<&[f32]>,
2295) -> Vec<f32> {
2296    assert_eq!(q.len(), n_heads * qk_head_dim);
2297    assert_eq!(k_cache.len(), seq_len * n_heads * qk_head_dim);
2298    assert_eq!(v_cache.len(), seq_len * n_heads * v_head_dim);
2299    if let Some(visible) = visible {
2300        assert!(
2301            visible.iter().all(|&t| t < seq_len),
2302            "visible positions must be within seq_len"
2303        );
2304    }
2305    if let Some(sinks) = sinks {
2306        assert_eq!(
2307            sinks.len(),
2308            n_heads,
2309            "one sink logit per query head, or none at all"
2310        );
2311    }
2312
2313    // Indexed rather than materialized: `None` means every position
2314    // through `seq_len`, and building that list would allocate one
2315    // `usize` per cached token on every decode step of every layer --
2316    // paid on the dense path, which is the common one.
2317    let n_positions = visible.map_or(seq_len, |v| v.len());
2318    let position_at = |i: usize| visible.map_or(i, |v| v[i]);
2319
2320    let scale = 1.0 / (qk_head_dim as f32).sqrt();
2321    let mut out = vec![0f32; n_heads * v_head_dim];
2322
2323    for h in 0..n_heads {
2324        let q_h = &q[h * qk_head_dim..(h + 1) * qk_head_dim];
2325
2326        let mut scores = vec![0f32; n_positions];
2327        for (i, score) in scores.iter_mut().enumerate() {
2328            let t = position_at(i);
2329            let k_t =
2330                &k_cache[(t * n_heads + h) * qk_head_dim..(t * n_heads + h + 1) * qk_head_dim];
2331            let mut dot = 0f32;
2332            for d in 0..qk_head_dim {
2333                dot += q_h[d] * k_t[d];
2334            }
2335            *score = dot * scale;
2336        }
2337
2338        let sink = sinks.map(|s| s[h]);
2339        let mut max = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
2340        if let Some(s) = sink {
2341            max = max.max(s);
2342        }
2343        let mut sum = 0f32;
2344        for s in scores.iter_mut() {
2345            *s = (*s - max).exp();
2346            sum += *s;
2347        }
2348        // The sink's mass lands in the denominator only: it has no value
2349        // vector, which is exactly how it removes weight from the real
2350        // keys instead of redistributing it among them.
2351        if let Some(s) = sink {
2352            sum += (s - max).exp();
2353        }
2354        if sum > 0.0 {
2355            for s in scores.iter_mut() {
2356                *s /= sum;
2357            }
2358        }
2359
2360        let out_h = &mut out[h * v_head_dim..(h + 1) * v_head_dim];
2361        for (i, &w) in scores.iter().enumerate() {
2362            let t = position_at(i);
2363            let v_t = &v_cache[(t * n_heads + h) * v_head_dim..(t * n_heads + h + 1) * v_head_dim];
2364            for d in 0..v_head_dim {
2365                out_h[d] += w * v_t[d];
2366            }
2367        }
2368    }
2369
2370    out
2371}
2372
2373/// [`causal_mla_attention`] with DeepSeek V4's per-head attention sinks.
2374///
2375/// `sinks` is one learned logit per query head. See
2376/// [`mla_attention_inner`] for what a sink does and why it is not
2377/// scaled.
2378#[allow(clippy::too_many_arguments)]
2379pub fn causal_mla_attention_sinks(
2380    q: &[f32],
2381    k_cache: &[f32],
2382    v_cache: &[f32],
2383    n_heads: usize,
2384    qk_head_dim: usize,
2385    v_head_dim: usize,
2386    seq_len: usize,
2387    sinks: Option<&[f32]>,
2388) -> Vec<f32> {
2389    mla_attention_inner(
2390        q,
2391        k_cache,
2392        v_cache,
2393        n_heads,
2394        qk_head_dim,
2395        v_head_dim,
2396        seq_len,
2397        None,
2398        sinks,
2399    )
2400}
2401
2402/// [`causal_mla_attention_sparse`] with per-head attention sinks.
2403///
2404/// The sink matters more here than on the dense path: a sparse query
2405/// sees only the positions the indexer selected, and without a sink its
2406/// softmax is forced to spend a full unit of weight on them however
2407/// poorly they match.
2408#[allow(clippy::too_many_arguments)]
2409pub fn causal_mla_attention_sparse_sinks(
2410    q: &[f32],
2411    k_cache: &[f32],
2412    v_cache: &[f32],
2413    n_heads: usize,
2414    qk_head_dim: usize,
2415    v_head_dim: usize,
2416    seq_len: usize,
2417    visible: &[usize],
2418    sinks: Option<&[f32]>,
2419) -> Vec<f32> {
2420    mla_attention_inner(
2421        q,
2422        k_cache,
2423        v_cache,
2424        n_heads,
2425        qk_head_dim,
2426        v_head_dim,
2427        seq_len,
2428        Some(visible),
2429        sinks,
2430    )
2431}
2432
2433/// The DeepSeek-V3.2 / GLM-5.2 "lightning indexer" (arXiv 2512.02556;
2434/// real, merged, tested reference implementations in llama.cpp PR
2435/// #23346 and PR #25407): scores every causally-visible key position
2436/// against the query using a cheap multi-head dot-product indexer, then
2437/// keeps only the `top_k` highest-scoring positions.
2438///
2439/// `indexer_q` is `[n_index_heads][index_head_dim]` for this query
2440/// position; `indexer_keys` is `[num_causal_positions][index_head_dim]`
2441/// (one MQA key per causal position, `0..=query_pos`); `indexer_weights`
2442/// is `[n_index_heads]`. Returns the kept key positions, ascending.
2443///
2444/// The real implementation additionally rotates `indexer_q`/`indexer_k`
2445/// through a fixed orthogonal Hadamard matrix before the dot product (to
2446/// spread values evenly for FP8 quantization on real hardware). An
2447/// orthogonal transform applied identically to both operands leaves
2448/// their dot product unchanged in exact arithmetic
2449/// (`(Hq)·(Hk) = q^T H^T H k = q^T k`), so this f32 CPU path omits it —
2450/// the score computed here is exact, not an approximation of the real
2451/// one.
2452pub fn lightning_indexer_topk(
2453    indexer_q: &[Vec<f32>],
2454    indexer_keys: &[Vec<f32>],
2455    indexer_weights: &[f32],
2456    top_k: usize,
2457) -> Vec<usize> {
2458    let n_heads = indexer_q.len();
2459    assert_eq!(indexer_weights.len(), n_heads);
2460    let index_head_dim = indexer_q.first().map_or(0, |q| q.len());
2461    let scale = 1.0 / ((index_head_dim * n_heads) as f32).sqrt();
2462
2463    let mut scored: Vec<(usize, f32)> = indexer_keys
2464        .iter()
2465        .enumerate()
2466        .map(|(j, k)| {
2467            let score: f32 = indexer_q
2468                .iter()
2469                .zip(indexer_weights.iter())
2470                .map(|(q, w)| {
2471                    let dot: f32 = q.iter().zip(k.iter()).map(|(a, b)| a * b).sum();
2472                    dot.max(0.0) * w * scale
2473                })
2474                .sum();
2475            (j, score)
2476        })
2477        .collect();
2478
2479    let keep = top_k.min(scored.len());
2480    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
2481    let mut kept: Vec<usize> = scored.into_iter().take(keep).map(|(j, _)| j).collect();
2482    kept.sort_unstable();
2483    kept
2484}
2485
2486/// Same as [`causal_mla_attention`] for a single query position, but
2487/// attention is restricted to the explicit `visible` key positions
2488/// (ascending, a subset of `0..seq_len`) rather than the full causal
2489/// history — the sparse-attention half of GLM-5.2/DeepSeek-V3.2's DSA,
2490/// applied after [`lightning_indexer_topk`] selects `visible`.
2491#[allow(clippy::too_many_arguments)]
2492pub fn causal_mla_attention_sparse(
2493    q: &[f32],
2494    k_cache: &[f32],
2495    v_cache: &[f32],
2496    n_heads: usize,
2497    qk_head_dim: usize,
2498    v_head_dim: usize,
2499    seq_len: usize,
2500    visible: &[usize],
2501) -> Vec<f32> {
2502    mla_attention_inner(
2503        q,
2504        k_cache,
2505        v_cache,
2506        n_heads,
2507        qk_head_dim,
2508        v_head_dim,
2509        seq_len,
2510        Some(visible),
2511        None,
2512    )
2513}
2514
2515#[cfg(test)]
2516mod tests {
2517
2518    /// A sink takes probability mass away from the real keys without
2519    /// contributing to the output, so the result shrinks toward zero
2520    /// rather than being redistributed. Without a sink the softmax must
2521    /// spend a full unit of weight on the keys it has, however poorly
2522    /// they match; with one, a head can decline.
2523    #[test]
2524    fn an_mla_sink_removes_weight_from_the_real_keys_instead_of_moving_it() {
2525        let (n_heads, qk, vd, seq) = (2, 2, 2, 2);
2526        let q = vec![1.0, 0.0, 0.0, 1.0];
2527        let k = vec![1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
2528        let v = vec![4.0, 8.0, 1.0, 2.0, 4.0, 8.0, 1.0, 2.0];
2529
2530        let plain = super::causal_mla_attention(&q, &k, &v, n_heads, qk, vd, seq);
2531        let none = super::causal_mla_attention_sinks(&q, &k, &v, n_heads, qk, vd, seq, None);
2532        assert_eq!(plain, none, "no sink must be exactly the old path");
2533
2534        // A sink far above every score takes nearly all the mass.
2535        let big = super::causal_mla_attention_sinks(
2536            &q,
2537            &k,
2538            &v,
2539            n_heads,
2540            qk,
2541            vd,
2542            seq,
2543            Some(&[40.0, 40.0]),
2544        );
2545        for (b, p) in big.iter().zip(plain.iter()) {
2546            assert!(b.abs() < 1e-6, "a dominant sink leaves ~0, got {b} vs {p}");
2547        }
2548
2549        // A sink far below every score changes almost nothing.
2550        let tiny = super::causal_mla_attention_sinks(
2551            &q,
2552            &k,
2553            &v,
2554            n_heads,
2555            qk,
2556            vd,
2557            seq,
2558            Some(&[-40.0, -40.0]),
2559        );
2560        for (s, p) in tiny.iter().zip(plain.iter()) {
2561            assert!((s - p).abs() < 1e-5, "negligible sink: {s} vs {p}");
2562        }
2563    }
2564
2565    /// The sink is per HEAD, so one head may decline while another
2566    /// attends normally. A single shared sink would be a different
2567    /// mechanism, and one that cannot express this.
2568    #[test]
2569    fn each_head_gets_its_own_mla_sink() {
2570        let (n_heads, qk, vd, seq) = (2, 1, 1, 1);
2571        let q = vec![1.0, 1.0];
2572        let k = vec![1.0, 1.0];
2573        let v = vec![5.0, 5.0];
2574
2575        let out = super::causal_mla_attention_sinks(
2576            &q,
2577            &k,
2578            &v,
2579            n_heads,
2580            qk,
2581            vd,
2582            seq,
2583            Some(&[40.0, -40.0]),
2584        );
2585        assert!(out[0].abs() < 1e-6, "head 0 declined: {}", out[0]);
2586        assert!(
2587            (out[1] - 5.0).abs() < 1e-5,
2588            "head 1 attended normally: {}",
2589            out[1]
2590        );
2591    }
2592
2593    /// The sink logit is NOT multiplied by the `1/sqrt(qk_head_dim)`
2594    /// score scale -- it is a learned logit already in score space. If
2595    /// it were scaled, the same checkpoint would sink differently at
2596    /// different head widths, which is what this pins down.
2597    #[test]
2598    fn the_mla_sink_logit_is_not_scaled_by_the_head_width() {
2599        // One key whose score is exactly 0 before and after scaling, so
2600        // the only thing the head width can affect is the sink.
2601        let sink = 0.0f32;
2602        let mut outs = Vec::new();
2603        for qk in [1usize, 4, 16] {
2604            let q = vec![0.0; qk];
2605            let k = vec![0.0; qk];
2606            let v = vec![10.0];
2607            outs.push(super::causal_mla_attention_sinks(&q, &k, &v, 1, qk, 1, 1, Some(&[sink]))[0]);
2608        }
2609        // score 0 and sink 0 split the mass evenly, at every width.
2610        for o in &outs {
2611            assert!((o - 5.0).abs() < 1e-5, "expected 5.0, got {o}");
2612        }
2613    }
2614
2615    /// The sparse path takes a sink too, and it matters more there: a
2616    /// query that sees only the indexer's selection would otherwise be
2617    /// forced to spend a full unit of weight on it.
2618    #[test]
2619    fn the_sparse_mla_path_honours_a_sink_over_the_selected_positions() {
2620        let (n_heads, qk, vd, seq) = (1, 1, 1, 3);
2621        let q = vec![1.0];
2622        let k = vec![1.0, 1.0, 1.0];
2623        let v = vec![2.0, 4.0, 6.0];
2624        let visible = [0usize, 2];
2625
2626        let plain = super::causal_mla_attention_sparse(&q, &k, &v, n_heads, qk, vd, seq, &visible);
2627        let none = super::causal_mla_attention_sparse_sinks(
2628            &q, &k, &v, n_heads, qk, vd, seq, &visible, None,
2629        );
2630        assert_eq!(plain, none);
2631        assert!((plain[0] - 4.0).abs() < 1e-5, "mean of 2 and 6");
2632
2633        let sunk = super::causal_mla_attention_sparse_sinks(
2634            &q,
2635            &k,
2636            &v,
2637            n_heads,
2638            qk,
2639            vd,
2640            seq,
2641            &visible,
2642            Some(&[40.0]),
2643        );
2644        assert!(sunk[0].abs() < 1e-6, "a dominant sink leaves ~0");
2645    }
2646    #[test]
2647    fn prefill_shared_kv_matches_per_query_reference() {
2648        // Shapes chosen to cross the query-block boundary (n_q > 2 blocks,
2649        // with a partial last block), with a nonzero decoded prefix and
2650        // grouped KV heads; softcap both off and on. The blocked
2651        // three-pass softmax must agree with the per-query online
2652        // accumulator within float noise.
2653        let n_heads = 6;
2654        let n_kv_heads = 2;
2655        let head_dim = 16;
2656        let n_q = 19;
2657        let kv_prefix = 5;
2658        let kv_len = kv_prefix + n_q;
2659        let q_stride = n_heads * head_dim;
2660        let kv_stride = n_kv_heads * head_dim;
2661
2662        let q: Vec<f32> = (0..n_q * q_stride)
2663            .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2664            .collect();
2665        let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2666            .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2667            .collect();
2668        let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2669            .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2670            .collect();
2671
2672        for softcap in [None, Some(30.0)] {
2673            let got = super::causal_gqa_attention_prefill_shared_kv(
2674                &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, softcap,
2675            );
2676            assert_eq!(got.len(), n_q * q_stride);
2677            for b in 0..n_q {
2678                let causal_len = kv_prefix + b + 1;
2679                let want = super::causal_gqa_attention_softcap(
2680                    &q[b * q_stride..(b + 1) * q_stride],
2681                    &k_cache[..causal_len * kv_stride],
2682                    &v_cache[..causal_len * kv_stride],
2683                    n_heads,
2684                    n_kv_heads,
2685                    head_dim,
2686                    causal_len,
2687                    softcap,
2688                );
2689                for (i, (g, w)) in got[b * q_stride..(b + 1) * q_stride]
2690                    .iter()
2691                    .zip(want.iter())
2692                    .enumerate()
2693                {
2694                    assert!(
2695                        (g - w).abs() < 1e-5,
2696                        "softcap {softcap:?} query {b} slot {i}: blocked {g} vs online {w}"
2697                    );
2698                }
2699            }
2700        }
2701    }
2702
2703    #[test]
2704    fn windowed_prefill_shared_kv_matches_the_per_query_windowed_reference() {
2705        // Same shapes as `prefill_shared_kv_matches_per_query_reference`,
2706        // now against `causal_gqa_attention_windowed_softcap` — the
2707        // per-query path the decoder's SWA arm used to call. Windows are
2708        // chosen to sit below, across and above the causal prefix so the
2709        // `saturating_sub` boundary is exercised on both sides; the last
2710        // one degenerates to full causal and must match the unwindowed
2711        // kernel too.
2712        let n_heads = 6;
2713        let n_kv_heads = 2;
2714        let head_dim = 16;
2715        let n_q = 19;
2716        let kv_prefix = 5;
2717        let kv_len = kv_prefix + n_q;
2718        let q_stride = n_heads * head_dim;
2719        let kv_stride = n_kv_heads * head_dim;
2720
2721        let q: Vec<f32> = (0..n_q * q_stride)
2722            .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2723            .collect();
2724        let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2725            .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2726            .collect();
2727        let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2728            .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2729            .collect();
2730
2731        for window in [1usize, 3, 7, kv_prefix, kv_len, kv_len + 8] {
2732            for softcap in [None, Some(30.0)] {
2733                let got = super::causal_gqa_attention_prefill_shared_kv_windowed(
2734                    &q,
2735                    &k_cache,
2736                    &v_cache,
2737                    n_heads,
2738                    n_kv_heads,
2739                    head_dim,
2740                    n_q,
2741                    kv_prefix,
2742                    softcap,
2743                    Some(window),
2744                );
2745                assert_eq!(got.len(), n_q * q_stride);
2746                for b in 0..n_q {
2747                    let causal_len = kv_prefix + b + 1;
2748                    let want = super::causal_gqa_attention_windowed_softcap(
2749                        &q[b * q_stride..(b + 1) * q_stride],
2750                        &k_cache[..causal_len * kv_stride],
2751                        &v_cache[..causal_len * kv_stride],
2752                        n_heads,
2753                        n_kv_heads,
2754                        head_dim,
2755                        causal_len,
2756                        window,
2757                        softcap,
2758                    );
2759                    for (i, (g, w)) in got[b * q_stride..(b + 1) * q_stride]
2760                        .iter()
2761                        .zip(want.iter())
2762                        .enumerate()
2763                    {
2764                        assert!(
2765                            (g - w).abs() < 1e-5,
2766                            "window {window} softcap {softcap:?} query {b} slot {i}: \
2767                             blocked {g} vs per-query {w}"
2768                        );
2769                    }
2770                }
2771            }
2772        }
2773
2774        // `window >= kv_len` is full causal: identical to `None`.
2775        let windowed = super::causal_gqa_attention_prefill_shared_kv_windowed(
2776            &q,
2777            &k_cache,
2778            &v_cache,
2779            n_heads,
2780            n_kv_heads,
2781            head_dim,
2782            n_q,
2783            kv_prefix,
2784            None,
2785            Some(kv_len + 8),
2786        );
2787        let full = super::causal_gqa_attention_prefill_shared_kv(
2788            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, None,
2789        );
2790        assert_eq!(windowed, full);
2791    }
2792
2793    /// The query-outer form the blocked kernel had before the K/V rows
2794    /// were hoisted to the outer loop: one query at a time, streaming
2795    /// the whole visible K slab and then the whole visible V slab.
2796    /// Built from the same `dot_f32` / `softmax_row_exp_sum` / `axpy` /
2797    /// `scale_inplace` primitives, so the kernel must match it **bit for
2798    /// bit**, not within a tolerance: reordering which rows are loaded
2799    /// when must not reorder any arithmetic. What those primitives
2800    /// compute is checked separately, `dot_f32` against a scalar sum and
2801    /// `softmax_row_exp_sum` against libm's own `expf` in
2802    /// `vectorised_softmax_row_matches_the_scalar_libm_form`.
2803    #[allow(clippy::too_many_arguments)]
2804    fn prefill_query_outer_reference(
2805        q: &[f32],
2806        k_cache: &[f32],
2807        v_cache: &[f32],
2808        n_heads: usize,
2809        n_kv_heads: usize,
2810        head_dim: usize,
2811        n_q: usize,
2812        kv_prefix: usize,
2813        attn_softcap: Option<f32>,
2814        window: Option<usize>,
2815    ) -> Vec<f32> {
2816        let q_stride = n_heads * head_dim;
2817        let group_size = n_heads / n_kv_heads.max(1);
2818        let scale = 1.0 / (head_dim as f32).sqrt();
2819        let softcap = attn_softcap.filter(|&c| c > 0.0);
2820        let mut out = vec![0f32; n_q * q_stride];
2821        let mut acc = vec![0f32; head_dim];
2822        for h in 0..n_heads {
2823            let kv_h = h / group_size.max(1);
2824            for b in 0..n_q {
2825                let causal_len = kv_prefix + b + 1;
2826                let t_start = match window {
2827                    Some(w) => causal_len.saturating_sub(w),
2828                    None => 0,
2829                };
2830                let q_h = &q[b * q_stride + h * head_dim..][..head_dim];
2831                let mut scores = vec![0f32; causal_len - t_start];
2832                for (i, s) in scores.iter_mut().enumerate() {
2833                    let base = ((t_start + i) * n_kv_heads + kv_h) * head_dim;
2834                    let mut v = super::dot_f32(q_h, &k_cache[base..base + head_dim]) * scale;
2835                    if let Some(sc) = softcap {
2836                        v = sc * (v / sc).tanh();
2837                    }
2838                    *s = v;
2839                }
2840                let l = super::softmax_row_exp_sum(&mut scores);
2841                acc.fill(0.0);
2842                for (i, &p) in scores.iter().enumerate() {
2843                    let base = ((t_start + i) * n_kv_heads + kv_h) * head_dim;
2844                    super::axpy(&mut acc, &v_cache[base..base + head_dim], p);
2845                }
2846                if l > 0.0 {
2847                    super::scale_inplace(&mut acc, 1.0 / l);
2848                }
2849                out[b * q_stride + h * head_dim..][..head_dim].copy_from_slice(&acc);
2850            }
2851        }
2852        out
2853    }
2854
2855    #[test]
2856    fn position_outer_prefill_is_bit_identical_to_the_query_outer_form() {
2857        // Shapes cross the query-block boundary with a partial last
2858        // block, a nonzero decoded prefix and grouped KV heads. Windows
2859        // are chosen so the visible span is narrower than, equal to and
2860        // wider than the block, plus the unwindowed case.
2861        let n_heads = 6;
2862        let n_kv_heads = 2;
2863        let head_dim = 16;
2864        let n_q = 19;
2865        let kv_prefix = 5;
2866        let kv_len = kv_prefix + n_q;
2867        let q_stride = n_heads * head_dim;
2868        let kv_stride = n_kv_heads * head_dim;
2869
2870        let q: Vec<f32> = (0..n_q * q_stride)
2871            .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2872            .collect();
2873        let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2874            .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2875            .collect();
2876        let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2877            .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2878            .collect();
2879
2880        for window in [None, Some(1), Some(3), Some(8), Some(9), Some(kv_len + 4)] {
2881            for softcap in [None, Some(30.0)] {
2882                let got = super::causal_gqa_attention_prefill_shared_kv_windowed(
2883                    &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, softcap,
2884                    window,
2885                );
2886                let want = prefill_query_outer_reference(
2887                    &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix, softcap,
2888                    window,
2889                );
2890                assert_eq!(got, want, "window {window:?} softcap {softcap:?}");
2891            }
2892        }
2893    }
2894
2895    #[test]
2896    fn tiled_prefill_gemm_is_bit_identical_across_awkward_shapes() {
2897        // `qk_tile` works a 4-query × 4-key register tile and `pv_tile`
2898        // an 8-query × 8-dim one, each with a row-at-a-time edge path
2899        // for what the tile does not cover. Every one of those edges,
2900        // and every `head_dim` width a real checkpoint uses, has to land
2901        // on the *same* arithmetic as the row-at-a-time form — so this
2902        // asserts bit equality against `prefill_query_outer_reference`,
2903        // not a tolerance.
2904        //
2905        // Swept: head_dim 64 (Llama/SmolLM2/Qwen3), 80 (Phi-4-mini), 128
2906        // (Qwen2.5/Mistral), 256 (Gemma-3); head counts that are not a
2907        // multiple of the tile; `n_q` below, on and off both tile
2908        // boundaries; GQA and MQA grouping; windows narrower than the
2909        // prompt (so the visible span is narrower than a query block);
2910        // and softcap on and off.
2911        let shapes = [
2912            (4usize, 4usize, 64usize),
2913            (6, 2, 64),
2914            (5, 1, 80),
2915            (3, 3, 128),
2916            (2, 1, 256),
2917        ];
2918        let batches = [(19usize, 5usize), (8, 0), (3, 7), (16, 1), (7, 0)];
2919        for &(n_heads, n_kv_heads, head_dim) in &shapes {
2920            let q_stride = n_heads * head_dim;
2921            let kv_stride = n_kv_heads * head_dim;
2922            for &(n_q, kv_prefix) in &batches {
2923                let kv_len = kv_prefix + n_q;
2924                let q: Vec<f32> = (0..n_q * q_stride)
2925                    .map(|i| ((i as f32) * 0.013 - 0.7).sin() * 1.3)
2926                    .collect();
2927                let k_cache: Vec<f32> = (0..kv_len * kv_stride)
2928                    .map(|i| ((i as f32) * 0.017 - 0.3).cos() * 1.1)
2929                    .collect();
2930                let v_cache: Vec<f32> = (0..kv_len * kv_stride)
2931                    .map(|i| ((i as f32) * 0.011 + 0.2).sin() * 0.9)
2932                    .collect();
2933                for window in [None, Some(2), Some(5), Some(9), Some(kv_len + 3)] {
2934                    for softcap in [None, Some(30.0)] {
2935                        let got = super::causal_gqa_attention_prefill_shared_kv_windowed(
2936                            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix,
2937                            softcap, window,
2938                        );
2939                        let want = prefill_query_outer_reference(
2940                            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, n_q, kv_prefix,
2941                            softcap, window,
2942                        );
2943                        assert_eq!(
2944                            got, want,
2945                            "heads {n_heads}/{n_kv_heads} head_dim {head_dim} n_q {n_q} \
2946                             kv_prefix {kv_prefix} window {window:?} softcap {softcap:?}"
2947                        );
2948                    }
2949                }
2950            }
2951        }
2952    }
2953
2954    /// The blocked kernel and its query-outer reference share
2955    /// `softmax_row_exp_sum`, so their bit-equality test cannot see a
2956    /// wrong exponential -- it would be equally wrong on both sides.
2957    /// This is the test that can: the vectorised routine against
2958    /// `f32::exp`, i.e. against libm, which is what the kernel called
2959    /// before.
2960    ///
2961    /// Swept: every length from 0 through twice the widest vector so all
2962    /// four NEON and all eight AVX2 tail positions are hit; rows whose
2963    /// spread is far past the `-87` clamp, where the vector form floors
2964    /// at `~1.6e-38` and libm returns a true zero; a constant row, where
2965    /// every term is `exp(0)` and the sum must come out at exactly the
2966    /// row length; and a row of one.
2967    #[test]
2968    fn vectorised_softmax_row_matches_the_scalar_libm_form() {
2969        fn libm_reference(x: &[f32]) -> (Vec<f32>, f32) {
2970            let m = x.iter().fold(f32::NEG_INFINITY, |a, &s| a.max(s));
2971            let out: Vec<f32> = x.iter().map(|s| (s - m).exp()).collect();
2972            let mut l = 0f32;
2973            for &e in out.iter() {
2974                l += e;
2975            }
2976            (out, l)
2977        }
2978
2979        // `spread` scales the score range: 200.0 pushes the low tail
2980        // past the clamp, 0.0 makes every score identical.
2981        for spread in [1.0f32, 8.0, 200.0, 0.0] {
2982            for n in (0..=17).chain([31, 32, 33, 64, 127, 512]) {
2983                let row: Vec<f32> = (0..n)
2984                    .map(|i| ((i as f32) * 0.37 - 1.1).sin() * spread)
2985                    .collect();
2986                let (want, want_l) = libm_reference(&row);
2987
2988                let mut got = row.clone();
2989                let got_l = super::softmax_row_exp_sum(&mut got);
2990
2991                for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
2992                    assert!(
2993                        (g - w).abs() <= 1e-6 * w + 1e-30,
2994                        "spread {spread} n {n} slot {i}: vector {g} vs libm {w}"
2995                    );
2996                }
2997                assert!(
2998                    (got_l - want_l).abs() <= 1e-5 * want_l.max(1.0),
2999                    "spread {spread} n {n}: sum {got_l} vs libm {want_l}"
3000                );
3001                if n == 0 {
3002                    assert_eq!(got_l, 0.0, "an empty visible range normalises to nothing");
3003                }
3004                if spread == 0.0 && n > 0 {
3005                    // Every score equal means every term is `exp(0)`, and
3006                    // the routine has to return that as *exactly* 1.0 --
3007                    // an approximation that drifts at zero would bias
3008                    // every uniform attention row.
3009                    for (i, g) in got.iter().enumerate() {
3010                        assert_eq!(*g, 1.0, "n {n} slot {i}: exp(0) must be exact");
3011                    }
3012                }
3013            }
3014        }
3015    }
3016
3017    /// Replacing libm's `expf` and a sequential `f32` sum changes the
3018    /// last bits of every attention probability, and across a 26-layer
3019    /// prefill that is enough to move a greedy argmax on a near-tie. So
3020    /// "different from what the scalar form produced" is not the
3021    /// question worth asking, because the answer is yes and will stay
3022    /// yes. "Further from the true softmax" is the question, and this
3023    /// answers it against an `f64` ground truth.
3024    ///
3025    /// Measured on this sweep: the probabilities come out at the same
3026    /// accuracy as the scalar form (both within a factor of two of each
3027    /// other, both growing together with the spread of the row, because
3028    /// the shared error term is rounding `score - max` into `f32`, not
3029    /// the exponential); the normaliser comes out **better**, by 3x to
3030    /// 10x, because four partial sums is a pairwise reduction and
3031    /// `l += *s` down the row is not.
3032    #[test]
3033    fn the_vectorised_softmax_is_no_less_accurate_than_the_scalar_one() {
3034        for spread in [1.0f64, 6.0, 20.0] {
3035            for n in [64usize, 253, 512] {
3036                let row: Vec<f64> = (0..n)
3037                    .map(|i| ((i as f64) * 0.37 - 1.1).sin() * spread)
3038                    .collect();
3039
3040                // Ground truth: the same reduction in `f64`.
3041                let m64 = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
3042                let truth: Vec<f64> = row.iter().map(|s| (s - m64).exp()).collect();
3043                let truth_l: f64 = truth.iter().sum();
3044
3045                let f32_row: Vec<f32> = row.iter().map(|&s| s as f32).collect();
3046
3047                let mut vector = f32_row.clone();
3048                let vector_l = super::softmax_row_exp_sum(&mut vector);
3049                let mut scalar = f32_row.clone();
3050                let scalar_l = super::softmax_row_exp_sum_scalar(&mut scalar);
3051
3052                let worst = |got: &[f32]| -> f64 {
3053                    got.iter()
3054                        .zip(truth.iter())
3055                        .map(|(&g, &t)| ((g as f64) - t).abs() / t)
3056                        .fold(0.0, f64::max)
3057                };
3058                let (ev, es) = (worst(&vector), worst(&scalar));
3059                let lv = ((vector_l as f64) - truth_l).abs() / truth_l;
3060                let ls = ((scalar_l as f64) - truth_l).abs() / truth_l;
3061                let eps = f64::from(f32::EPSILON);
3062
3063                // Probabilities: the same accuracy, within a factor of
3064                // two either way. Neither form is the error term here --
3065                // rounding `score - max` into `f32` is, which is why the
3066                // error grows with the spread of the row and why both
3067                // forms grow with it together.
3068                assert!(
3069                    ev <= 2.0 * es.max(eps) && ev <= 64.0 * eps,
3070                    "spread {spread} n {n}: vector probabilities err {ev:e} \
3071                     against scalar {es:e}"
3072                );
3073
3074                // The normaliser: the vector form is the better one,
3075                // every time. Four (or eight) partial sums is a pairwise
3076                // reduction; `l += *s` down a 512-wide row is not.
3077                assert!(
3078                    lv <= ls.max(eps),
3079                    "spread {spread} n {n}: vector normaliser err {lv:e} \
3080                     against scalar {ls:e}"
3081                );
3082            }
3083        }
3084    }
3085
3086    /// The row max must be the true max whichever lane it lands in, and
3087    /// the largest term must come back as exactly `1.0`, because the
3088    /// caller divides by the sum rather than tracking a running maximum.
3089    #[test]
3090    fn softmax_row_finds_its_maximum_in_every_lane_position() {
3091        for n in 1usize..=20 {
3092            for peak in 0..n {
3093                let mut row: Vec<f32> = (0..n).map(|i| -(i as f32) - 3.0).collect();
3094                row[peak] = 12.5;
3095                let l = super::softmax_row_exp_sum(&mut row);
3096                assert_eq!(row[peak], 1.0, "n {n} peak {peak}: the max term is exp(0)");
3097                for (i, &p) in row.iter().enumerate() {
3098                    assert!(p <= 1.0, "n {n} peak {peak} slot {i}: {p} exceeds the max");
3099                }
3100                assert!(l >= 1.0, "n {n} peak {peak}: sum {l} must include the max");
3101            }
3102        }
3103    }
3104
3105    use super::*;
3106
3107    #[test]
3108    fn simd_dot_f32_matches_scalar_across_lengths() {
3109        // Cover exact-multiple and tail lengths around the SIMD width.
3110        for n in [1usize, 3, 4, 7, 8, 15, 16, 63, 128, 129] {
3111            let a: Vec<f32> = (0..n).map(|i| ((i as f32) * 0.31 - 2.0).sin()).collect();
3112            let b: Vec<f32> = (0..n).map(|i| ((i as f32) * 0.17 + 1.0).cos()).collect();
3113            let simd = dot_f32(&a, &b);
3114            let scalar: f32 = a.iter().zip(&b).map(|(x, y)| x * y).sum();
3115            assert!(
3116                (simd - scalar).abs() <= 1e-4 * scalar.abs().max(1.0),
3117                "n={n} simd={simd} scalar={scalar}"
3118            );
3119        }
3120    }
3121
3122    #[test]
3123    fn rope_preserves_vector_norm() {
3124        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3125        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3126        apply_rope(&mut v, 5, 10000.0);
3127        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3128        assert!(
3129            (norm_before - norm_after).abs() < 1e-4,
3130            "RoPE is a rotation and must preserve norm"
3131        );
3132    }
3133
3134    #[test]
3135    fn rope_back_inverts_rope() {
3136        let original = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3137        let mut v = original.clone();
3138        apply_rope(&mut v, 11, 10000.0);
3139        apply_rope_back(&mut v, 11, 10000.0);
3140        for (a, b) in v.iter().zip(original.iter()) {
3141            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3142        }
3143    }
3144
3145    #[test]
3146    fn rope_interleaved_back_inverts_interleaved() {
3147        let original = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3148        let mut v = original.clone();
3149        apply_rope_interleaved(&mut v, 11, 10000.0);
3150        apply_rope_interleaved_back(&mut v, 11, 10000.0);
3151        for (a, b) in v.iter().zip(original.iter()) {
3152            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3153        }
3154    }
3155
3156    #[test]
3157    fn rope_at_position_zero_is_identity() {
3158        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3159        let original = v.clone();
3160        apply_rope(&mut v, 0, 10000.0);
3161        for (a, b) in v.iter().zip(original.iter()) {
3162            assert!((a - b).abs() < 1e-5);
3163        }
3164    }
3165
3166    #[test]
3167    fn rope_with_all_ones_freq_factors_matches_plain_rope() {
3168        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3169        let mut plain = with_factors.clone();
3170        let ones = vec![1.0; 3];
3171        apply_rope_with_freq_factors(&mut with_factors, 7, 10000.0, &ones);
3172        apply_rope(&mut plain, 7, 10000.0);
3173        for (a, b) in with_factors.iter().zip(plain.iter()) {
3174            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3175        }
3176    }
3177
3178    #[test]
3179    fn rope_with_freq_factors_diverges_from_plain_rope_when_factors_are_not_one() {
3180        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3181        let mut plain = with_factors.clone();
3182        let factors = vec![0.5, 2.0, 1.0];
3183        apply_rope_with_freq_factors(&mut with_factors, 7, 10000.0, &factors);
3184        apply_rope(&mut plain, 7, 10000.0);
3185        let differs = with_factors
3186            .iter()
3187            .zip(plain.iter())
3188            .any(|(a, b)| (a - b).abs() > 1e-4);
3189        assert!(differs, "non-1.0 freq_factors must change the rotation");
3190    }
3191
3192    #[test]
3193    fn rope_with_freq_factors_preserves_vector_norm() {
3194        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3195        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3196        apply_rope_with_freq_factors(&mut v, 5, 10000.0, &[0.8, 1.3]);
3197        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3198        assert!((norm_before - norm_after).abs() < 1e-4);
3199    }
3200
3201    /// The correction range's high end is clamped to `rotary_dim - 1`,
3202    /// which is *above* the ramp's own last index (`rotary_dim/2 - 1`),
3203    /// so the ramp never reaches `1.0` and the longest-wavelength bands
3204    /// stay partly extrapolated.
3205    ///
3206    /// **This test fails if `high` is clamped the naive way** (to
3207    /// `rotary_dim / 2 - 1`): that makes `ramp == 1.0` at the last band,
3208    /// whose divisor then becomes the full scaling factor `8.0` instead
3209    /// of the reference's `2.5366`. Numbers below are computed by hand
3210    /// from `_find_correction_dim` (`rotary.py:161`), not from this
3211    /// implementation: for `rotary_dim` 64, base 10000, original context
3212    /// 131072, `low = floor(22.5134) = 22` and
3213    /// `high = ceil(34.5546) = 35`.
3214    #[test]
3215    fn yarn_high_is_clamped_to_rotary_dim_minus_one_not_half_minus_one() {
3216        let scaling = YarnScaling::new(8.0, 131_072);
3217        let (low, high) = yarn_correction_range(scaling, 64, 10_000.0);
3218        assert!((low - 22.0).abs() < 1e-9, "low was {low}");
3219        assert!((high - 35.0).abs() < 1e-9, "high was {high}");
3220
3221        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3222        let last = factors[31];
3223        let ramp = (31.0 - 22.0) / (35.0 - 22.0);
3224        let want = 1.0 / (ramp / 8.0 + (1.0 - ramp));
3225        assert!(
3226            (last - want).abs() < 1e-4,
3227            "last band divisor {last} must be the reference's {want}"
3228        );
3229        assert!(
3230            (last - 8.0).abs() > 1.0,
3231            "clamping high to rotary_dim/2 - 1 would fully interpolate this band \
3232             (divisor 8.0, the whole factor); got {last}"
3233        );
3234    }
3235
3236    /// Band-by-band against the reference ramp
3237    /// (`rotary.py:181-187`), with `low`/`high` computed by hand as in
3238    /// the test above: bands at or below `low` are pure extrapolation
3239    /// (divisor exactly 1.0) and each band past it interpolates by
3240    /// `1 / (ramp/factor + 1 - ramp)`.
3241    #[test]
3242    fn yarn_freq_factors_match_the_reference_ramp_formula_band_by_band() {
3243        let scaling = YarnScaling::new(8.0, 131_072);
3244        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3245        assert_eq!(factors.len(), 32, "one divisor per rotation band");
3246        for band in [0usize, 10, 22] {
3247            assert!(
3248                (factors[band] - 1.0).abs() < 1e-6,
3249                "band {band} is at or below low=22 and must be left extrapolated, \
3250                 got {}",
3251                factors[band]
3252            );
3253        }
3254        for band in [23usize, 27, 31] {
3255            let ramp = (band as f32 - 22.0) / (35.0 - 22.0);
3256            let want = 1.0 / (ramp / 8.0 + (1.0 - ramp));
3257            assert!(
3258                (factors[band] - want).abs() < 1e-4,
3259                "band {band}: got {}, reference {want}",
3260                factors[band]
3261            );
3262        }
3263    }
3264
3265    /// A collapsed correction range (`low == high`, which
3266    /// `truncate: false` with `beta_fast == beta_slow` produces) is
3267    /// nudged by `+0.001`, making the ramp a step at `low`: the band
3268    /// below stays fully extrapolated and the band above is fully
3269    /// interpolated at the whole factor.
3270    ///
3271    /// **This test fails if the collapse is handled by flooring the gap
3272    /// at 1** (`high = low + 1`), the other obvious repair: band 23 is
3273    /// then only `0.4866` of the way up the ramp and its divisor is
3274    /// `1.7414`, not `8.0`.
3275    #[test]
3276    fn yarn_nudges_a_collapsed_correction_range_instead_of_flooring_the_gap_at_one() {
3277        let scaling = YarnScaling {
3278            beta_slow: 32.0,
3279            truncate: false,
3280            ..YarnScaling::new(8.0, 131_072)
3281        };
3282        let (low, high) = yarn_correction_range(scaling, 64, 10_000.0);
3283        // Hand-computed: _find_correction_dim(32) = 22.513440...
3284        assert!((low - 22.513_44).abs() < 1e-4, "low was {low}");
3285        assert!(
3286            (high - low - 0.001).abs() < 1e-9,
3287            "high must be low + 0.001, got {high}"
3288        );
3289
3290        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3291        assert!(
3292            (factors[22] - 1.0).abs() < 1e-6,
3293            "band below the step must be untouched, got {}",
3294            factors[22]
3295        );
3296        assert!(
3297            (factors[23] - 8.0).abs() < 1e-4,
3298            "band above the step must take the whole factor (a gap of 1 would \
3299             give 1.7414); got {}",
3300            factors[23]
3301        );
3302    }
3303
3304    /// `factor = 1.0` means "the served context is the trained context":
3305    /// every band's divisor must be exactly 1.0, i.e. mathematically the
3306    /// same rotation as no scaling at all. A checkpoint that declares
3307    /// YaRN with a no-op factor must not have its RoPE moved.
3308    #[test]
3309    fn yarn_with_a_factor_of_one_leaves_every_band_untouched() {
3310        let factors = yarn_freq_factors(YarnScaling::new(1.0, 4096), 32, 10_000.0);
3311        for (band, f) in factors.iter().enumerate() {
3312            assert!((f - 1.0).abs() < 1e-6, "band {band} moved to {f}");
3313        }
3314    }
3315
3316    /// The divisors are only a re-expression of the reference's
3317    /// rewritten `inv_freq`, so rotating through
3318    /// [`apply_rope_with_freq_factors`] must land on exactly the angle
3319    /// the reference's `inv_freq_new` implies. Checked against the
3320    /// reference formula (`inv_freq * (ramp/factor + 1 - ramp)`)
3321    /// evaluated here, not against this module's own divisor.
3322    #[test]
3323    fn yarn_divisors_reproduce_the_references_rewritten_frequencies() {
3324        let scaling = YarnScaling::new(8.0, 131_072);
3325        let factors = yarn_freq_factors(scaling, 64, 10_000.0);
3326
3327        let band = 31usize;
3328        let pos = 1024usize;
3329        let mut v = vec![0.0f32; 64];
3330        v[band] = 1.0;
3331        apply_rope_with_freq_factors(&mut v, pos, 10_000.0, &factors);
3332
3333        let ramp = (band as f64 - 22.0) / (35.0 - 22.0);
3334        let inv_freq = 1.0 / 10_000f64.powf((2 * band) as f64 / 64.0);
3335        let inv_freq_new = inv_freq * (ramp / 8.0 + (1.0 - ramp));
3336        let angle = pos as f64 * inv_freq_new;
3337        assert!(
3338            (v[band] as f64 - angle.cos()).abs() < 1e-5,
3339            "cos: {} vs {}",
3340            v[band],
3341            angle.cos()
3342        );
3343        assert!(
3344            (v[band + 32] as f64 - angle.sin()).abs() < 1e-5,
3345            "sin: {} vs {}",
3346            v[band + 32],
3347            angle.sin()
3348        );
3349    }
3350
3351    /// The proportional arm spaces frequencies over the *full* head
3352    /// while only the first `rotary_dim` channels rotate. Values are
3353    /// hand-computed from `rotary.py:103`'s
3354    /// `base ** (arange(0, head_size, 2) / head_size)` against this
3355    /// crate's own `base ** (2i / rotary_dim)` spacing: the divisor is
3356    /// their ratio.
3357    #[test]
3358    fn proportional_freq_factors_respace_frequencies_over_the_full_head() {
3359        let factors = proportional_freq_factors(128, 96, 10_000.0);
3360        assert_eq!(factors.len(), 48, "one divisor per rotated band");
3361        assert!((factors[0] - 1.0).abs() < 1e-6, "band 0 is 1/1");
3362        // 10000^(2/128 - 2/96) = 0.95316188...
3363        assert!(
3364            (factors[1] - 0.953_161_9).abs() < 1e-5,
3365            "band 1 was {}",
3366            factors[1]
3367        );
3368        // 10000^(94/128 - 94/96) = 0.10491397...
3369        assert!(
3370            (factors[47] - 0.104_913_97).abs() < 1e-5,
3371            "last band was {}",
3372            factors[47]
3373        );
3374    }
3375
3376    /// Full-width rope is the case where both spacings coincide, so the
3377    /// arm must be a no-op there rather than quietly re-scaling every
3378    /// band of an ordinary checkpoint.
3379    #[test]
3380    fn proportional_freq_factors_are_all_ones_when_the_whole_head_rotates() {
3381        for f in proportional_freq_factors(128, 128, 500_000.0) {
3382            assert!((f - 1.0).abs() < 1e-6, "full-width band moved to {f}");
3383        }
3384    }
3385
3386    #[test]
3387    fn rope_interleaved_preserves_vector_norm() {
3388        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3389        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3390        apply_rope_interleaved(&mut v, 5, 10000.0);
3391        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3392        assert!(
3393            (norm_before - norm_after).abs() < 1e-4,
3394            "RoPE is a rotation and must preserve norm"
3395        );
3396    }
3397
3398    #[test]
3399    fn rope_interleaved_at_position_zero_is_identity() {
3400        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3401        let original = v.clone();
3402        apply_rope_interleaved(&mut v, 0, 10000.0);
3403        for (a, b) in v.iter().zip(original.iter()) {
3404            assert!((a - b).abs() < 1e-5);
3405        }
3406    }
3407
3408    #[test]
3409    fn rope_interleaved_rotates_adjacent_pairs_not_split_halves() {
3410        // With a single frequency band (dim=2), interleaved and split-half
3411        // RoPE are mathematically identical (both rotate the one (v[0],
3412        // v[1]) pair). The two conventions only diverge once dim > 2 and
3413        // there's more than one frequency band to route pairs into --
3414        // that's the real bug class this test guards against: mixing up
3415        // which components get paired together.
3416        let mut interleaved = vec![1.0, 0.0, 0.0, 1.0];
3417        let mut split_half = interleaved.clone();
3418        apply_rope_interleaved(&mut interleaved, 3, 10000.0);
3419        apply_rope(&mut split_half, 3, 10000.0);
3420        // Different frequency assigned to each pair in the two
3421        // conventions (interleaved pairs (0,1)+(2,3), split-half pairs
3422        // (0,2)+(1,3)) so with two distinct frequency bands the outputs
3423        // must differ.
3424        let differs = interleaved
3425            .iter()
3426            .zip(split_half.iter())
3427            .any(|(a, b)| (a - b).abs() > 1e-4);
3428        assert!(differs, "the two RoPE conventions must not coincide here");
3429    }
3430
3431    #[test]
3432    fn rope_interleaved_with_all_ones_freq_factors_matches_plain_interleaved() {
3433        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3434        let mut plain = with_factors.clone();
3435        let ones = vec![1.0; 3];
3436        apply_rope_interleaved_with_freq_factors(&mut with_factors, 7, 10000.0, &ones);
3437        apply_rope_interleaved(&mut plain, 7, 10000.0);
3438        for (a, b) in with_factors.iter().zip(plain.iter()) {
3439            assert!((a - b).abs() < 1e-5, "{a} vs {b}");
3440        }
3441    }
3442
3443    #[test]
3444    fn rope_interleaved_with_freq_factors_diverges_when_factors_are_not_one() {
3445        let mut with_factors = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
3446        let mut plain = with_factors.clone();
3447        let factors = vec![0.5, 2.0, 1.0];
3448        apply_rope_interleaved_with_freq_factors(&mut with_factors, 7, 10000.0, &factors);
3449        apply_rope_interleaved(&mut plain, 7, 10000.0);
3450        let differs = with_factors
3451            .iter()
3452            .zip(plain.iter())
3453            .any(|(a, b)| (a - b).abs() > 1e-4);
3454        assert!(differs, "non-1.0 freq_factors must change the rotation");
3455    }
3456
3457    #[test]
3458    fn rope_interleaved_with_freq_factors_preserves_vector_norm() {
3459        let mut v = vec![1.0, 2.0, 3.0, 4.0];
3460        let norm_before: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3461        apply_rope_interleaved_with_freq_factors(&mut v, 5, 10000.0, &[0.8, 1.3]);
3462        let norm_after: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3463        assert!((norm_before - norm_after).abs() < 1e-4);
3464    }
3465
3466    #[test]
3467    fn attention_with_single_position_returns_that_value() {
3468        // With one cached position, attention weight is trivially 1.0,
3469        // so output must equal that single V vector regardless of Q/K.
3470        let q = vec![1.0, 0.0]; // 1 head, head_dim=2
3471        let k_cache = vec![0.5, 0.5]; // seq_len=1, 1 kv head
3472        let v_cache = vec![9.0, -3.0];
3473        let out = causal_gqa_attention(&q, &k_cache, &v_cache, 1, 1, 2, 1);
3474        assert!((out[0] - 9.0).abs() < 1e-4);
3475        assert!((out[1] - (-3.0)).abs() < 1e-4);
3476    }
3477
3478    #[test]
3479    fn gqa_group_mapping_shares_kv_heads_correctly() {
3480        // 4 query heads, 2 kv heads -> heads 0,1 use kv head 0; heads 2,3 use kv head 1.
3481        let head_dim = 2;
3482        let q = vec![
3483            1.0, 0.0, // head 0
3484            1.0, 0.0, // head 1
3485            1.0, 0.0, // head 2
3486            1.0, 0.0, // head 3
3487        ];
3488        // seq_len = 1, 2 kv heads
3489        let k_cache = vec![1.0, 0.0, 1.0, 0.0];
3490        let v_cache = vec![100.0, 100.0, 200.0, 200.0];
3491        let out = causal_gqa_attention(&q, &k_cache, &v_cache, 4, 2, head_dim, 1);
3492        // heads 0,1 -> kv head 0 -> v = [100,100]; heads 2,3 -> kv head 1 -> v=[200,200]
3493        assert_eq!(&out[0..2], &[100.0, 100.0][..]);
3494        assert_eq!(&out[2..4], &[100.0, 100.0][..]);
3495        assert_eq!(&out[4..6], &[200.0, 200.0][..]);
3496        assert_eq!(&out[6..8], &[200.0, 200.0][..]);
3497    }
3498
3499    #[test]
3500    fn prefill_gqa_matches_per_token_causal() {
3501        let n_heads = 4;
3502        let n_kv_heads = 2;
3503        let head_dim = 4;
3504        let seq_len = 5;
3505        let q: Vec<f32> = (0..seq_len * n_heads * head_dim)
3506            .map(|i| (i as f32 * 0.13).sin())
3507            .collect();
3508        let k: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3509            .map(|i| (i as f32 * 0.19).cos())
3510            .collect();
3511        let v: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3512            .map(|i| (i as f32 * 0.07).sin())
3513            .collect();
3514        let batched =
3515            causal_gqa_attention_prefill(&q, &k, &v, n_heads, n_kv_heads, head_dim, seq_len);
3516        let q_stride = n_heads * head_dim;
3517        let kv_stride = n_kv_heads * head_dim;
3518        for t in 0..seq_len {
3519            let expect = causal_gqa_attention(
3520                &q[t * q_stride..(t + 1) * q_stride],
3521                &k[..(t + 1) * kv_stride],
3522                &v[..(t + 1) * kv_stride],
3523                n_heads,
3524                n_kv_heads,
3525                head_dim,
3526                t + 1,
3527            );
3528            let got = &batched[t * q_stride..(t + 1) * q_stride];
3529            for (a, b) in got.iter().zip(expect.iter()) {
3530                assert!((a - b).abs() < 1e-5, "t={t}: {a} vs {b}");
3531            }
3532        }
3533    }
3534
3535    #[test]
3536    fn windowed_attention_with_window_covering_full_history_matches_full_causal() {
3537        let n_heads = 2;
3538        let n_kv_heads = 1;
3539        let head_dim = 3;
3540        let seq_len = 4;
3541
3542        let q: Vec<f32> = (0..n_heads * head_dim)
3543            .map(|i| (i as f32 * 0.3).sin())
3544            .collect();
3545        let k_cache: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3546            .map(|i| (i as f32 * 0.17).cos())
3547            .collect();
3548        let v_cache: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3549            .map(|i| (i as f32 * 0.11).sin())
3550            .collect();
3551
3552        let full = causal_gqa_attention(
3553            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, seq_len,
3554        );
3555        let windowed = causal_gqa_attention_windowed(
3556            &q, &k_cache, &v_cache, n_heads, n_kv_heads, head_dim, seq_len, seq_len,
3557        );
3558        assert_eq!(full.len(), windowed.len());
3559        for (a, b) in full.iter().zip(windowed.iter()) {
3560            assert_eq!(
3561                a.to_bits(),
3562                b.to_bits(),
3563                "window >= seq_len must be bit-identical to full causal"
3564            );
3565        }
3566    }
3567
3568    #[test]
3569    fn windowed_attention_ignores_positions_outside_the_window() {
3570        // 1 head, seq_len=3, window=1: only the current position (t=2)
3571        // should ever be attended to, so the output must equal exactly
3572        // that position's V vector regardless of Q/K -- masking every
3573        // earlier position out means there is exactly one candidate
3574        // left, and softmax over one candidate is trivially 1.0.
3575        let head_dim = 2;
3576        let q = vec![1.0, 0.0];
3577        let k_cache = vec![9.0, -9.0, 0.5, 0.5, -3.0, 7.0]; // seq_len=3, 1 kv head
3578        let v_cache = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0];
3579        let out = causal_gqa_attention_windowed(&q, &k_cache, &v_cache, 1, 1, head_dim, 3, 1);
3580        assert!((out[0] - 50.0).abs() < 1e-4);
3581        assert!((out[1] - 60.0).abs() < 1e-4);
3582    }
3583
3584    #[test]
3585    fn paged_attention_matches_contiguous_attention_bit_identical() {
3586        use crate::cache::{PagedKvCache, PagedKvStore};
3587
3588        let n_heads = 4;
3589        let n_kv_heads = 2;
3590        let head_dim = 3;
3591        let block_size = 2;
3592        let seq_len = 5;
3593
3594        // Deterministic pseudo-random-ish K/V/Q values, no RNG needed.
3595        let k_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3596            .map(|i| ((i * 7 + 1) % 13) as f32 * 0.1)
3597            .collect();
3598        let v_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3599            .map(|i| ((i * 5 + 3) % 11) as f32 * 0.1)
3600            .collect();
3601        let q: Vec<f32> = (0..n_heads * head_dim)
3602            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3603            .collect();
3604
3605        let contiguous =
3606            causal_gqa_attention(&q, &k_flat, &v_flat, n_heads, n_kv_heads, head_dim, seq_len);
3607
3608        let mut store = PagedKvStore::new(block_size, seq_len, n_kv_heads, head_dim);
3609        let mut cache = PagedKvCache::new();
3610        for t in 0..seq_len {
3611            let start = t * n_kv_heads * head_dim;
3612            let end = start + n_kv_heads * head_dim;
3613            cache
3614                .push(&mut store, &k_flat[start..end], &v_flat[start..end])
3615                .expect("store sized for seq_len blocks, must not exhaust");
3616        }
3617
3618        let paged = causal_gqa_attention_paged(
3619            &q,
3620            &store,
3621            cache.block_table(),
3622            n_heads,
3623            n_kv_heads,
3624            head_dim,
3625            seq_len,
3626        );
3627
3628        assert_eq!(contiguous.len(), paged.len());
3629        for (a, b) in contiguous.iter().zip(paged.iter()) {
3630            assert_eq!(a.to_bits(), b.to_bits(), "paged path must be bit-identical");
3631        }
3632    }
3633
3634    /// A helper for the paged/contiguous comparisons below: the same
3635    /// K/V pushed into a paged store, so only the ADDRESSING differs
3636    /// between the two kernels under test.
3637    fn paged_fixture(
3638        seq_len: usize,
3639        n_kv_heads: usize,
3640        head_dim: usize,
3641        block_size: usize,
3642    ) -> (Vec<f32>, Vec<f32>, crate::cache::PagedKvStore, Vec<usize>) {
3643        use crate::cache::{PagedKvCache, PagedKvStore};
3644        let k_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3645            .map(|i| ((i * 7 + 1) % 13) as f32 * 0.1)
3646            .collect();
3647        let v_flat: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3648            .map(|i| ((i * 5 + 3) % 11) as f32 * 0.1)
3649            .collect();
3650        let mut store = PagedKvStore::new(block_size, seq_len, n_kv_heads, head_dim);
3651        let mut cache = PagedKvCache::new();
3652        for t in 0..seq_len {
3653            let start = t * n_kv_heads * head_dim;
3654            let end = start + n_kv_heads * head_dim;
3655            cache
3656                .push(&mut store, &k_flat[start..end], &v_flat[start..end])
3657                .expect("store sized for seq_len blocks, must not exhaust");
3658        }
3659        let table = cache.block_table().to_vec();
3660        (k_flat, v_flat, store, table)
3661    }
3662
3663    /// The paged kernel's sink term must be BIT-identical to the
3664    /// contiguous one, not merely close.
3665    ///
3666    /// This is what let `forward_token_paged` stop refusing gpt-oss.
3667    /// The whole premise of moving a model onto paged KV is that its
3668    /// distribution does not change, so "within tolerance" is not the
3669    /// bar -- a distribution that differs in the last bit is still a
3670    /// different distribution, and it would show up as a model that
3671    /// answers differently depending on which cache it happened to be
3672    /// served from.
3673    #[test]
3674    fn the_paged_sink_term_is_bit_identical_to_the_contiguous_one() {
3675        let (n_heads, n_kv_heads, head_dim, seq_len, block_size) = (4, 2, 3, 5, 2);
3676        let (k_flat, v_flat, store, table) =
3677            paged_fixture(seq_len, n_kv_heads, head_dim, block_size);
3678        let q: Vec<f32> = (0..n_heads * head_dim)
3679            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3680            .collect();
3681
3682        // A spread of sinks, including one that dominates and one that
3683        // is negligible, so the comparison covers both ends of the
3684        // online-softmax rescale rather than a single middling value.
3685        let sinks = vec![-30.0f32, 0.0, 1.5, 30.0];
3686        let contiguous = causal_gqa_attention_sinks(
3687            &q, &k_flat, &v_flat, n_heads, n_kv_heads, head_dim, seq_len, None, &sinks,
3688        );
3689        let paged = causal_gqa_attention_paged_sinks(
3690            &q,
3691            &store,
3692            &table,
3693            n_heads,
3694            n_kv_heads,
3695            head_dim,
3696            seq_len,
3697            None,
3698            Some(&sinks),
3699            None,
3700        );
3701        assert_eq!(contiguous.len(), paged.len());
3702        for (i, (a, b)) in contiguous.iter().zip(paged.iter()).enumerate() {
3703            assert_eq!(a.to_bits(), b.to_bits(), "element {i}: {a} vs {b}");
3704        }
3705    }
3706
3707    /// The window arm too, at a width that really drops positions --
3708    /// and at one that covers the whole history, which must degenerate
3709    /// to full causal rather than to an off-by-one.
3710    #[test]
3711    fn the_paged_window_arm_is_bit_identical_to_the_contiguous_one() {
3712        let (n_heads, n_kv_heads, head_dim, seq_len, block_size) = (4, 2, 3, 7, 2);
3713        let (k_flat, v_flat, store, table) =
3714            paged_fixture(seq_len, n_kv_heads, head_dim, block_size);
3715        let q: Vec<f32> = (0..n_heads * head_dim)
3716            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3717            .collect();
3718        let sinks = vec![0.5f32; n_heads];
3719
3720        for window in [1usize, 2, 3, 6, 7, 99] {
3721            let contiguous = causal_gqa_attention_sinks(
3722                &q,
3723                &k_flat,
3724                &v_flat,
3725                n_heads,
3726                n_kv_heads,
3727                head_dim,
3728                seq_len,
3729                Some(window),
3730                &sinks,
3731            );
3732            let paged = causal_gqa_attention_paged_sinks(
3733                &q,
3734                &store,
3735                &table,
3736                n_heads,
3737                n_kv_heads,
3738                head_dim,
3739                seq_len,
3740                Some(window),
3741                Some(&sinks),
3742                None,
3743            );
3744            for (i, (a, b)) in contiguous.iter().zip(paged.iter()).enumerate() {
3745                assert_eq!(
3746                    a.to_bits(),
3747                    b.to_bits(),
3748                    "window {window} element {i}: {a} vs {b}"
3749                );
3750            }
3751        }
3752    }
3753
3754    /// With no sinks and no window it must reproduce the plain paged
3755    /// kernel exactly, so the new entry point is a strict superset
3756    /// rather than a second implementation that drifts from it.
3757    #[test]
3758    fn the_paged_sink_kernel_without_sinks_or_window_is_the_plain_paged_kernel() {
3759        let (n_heads, n_kv_heads, head_dim, seq_len, block_size) = (4, 2, 3, 5, 2);
3760        let (_k, _v, store, table) = paged_fixture(seq_len, n_kv_heads, head_dim, block_size);
3761        let q: Vec<f32> = (0..n_heads * head_dim)
3762            .map(|i| ((i * 3 + 2) % 9) as f32 * 0.1)
3763            .collect();
3764
3765        let plain =
3766            causal_gqa_attention_paged(&q, &store, &table, n_heads, n_kv_heads, head_dim, seq_len);
3767        let via_sinks = causal_gqa_attention_paged_sinks(
3768            &q, &store, &table, n_heads, n_kv_heads, head_dim, seq_len, None, None, None,
3769        );
3770        for (i, (a, b)) in plain.iter().zip(via_sinks.iter()).enumerate() {
3771            assert_eq!(a.to_bits(), b.to_bits(), "element {i}");
3772        }
3773    }
3774
3775    #[test]
3776    fn mla_attention_with_single_position_returns_that_value() {
3777        // Same reasoning as `attention_with_single_position_returns_that_value`,
3778        // but with distinct qk/v head dims (5 vs 3) to exercise the one real
3779        // difference from `causal_gqa_attention`.
3780        let q = vec![1.0, 0.0, 0.0, 0.0, 0.0]; // 1 head, qk_head_dim=5
3781        let k_cache = vec![0.2, 0.2, 0.2, 0.2, 0.2]; // seq_len=1
3782        let v_cache = vec![9.0, -3.0, 1.0]; // v_head_dim=3
3783        let out = causal_mla_attention(&q, &k_cache, &v_cache, 1, 5, 3, 1);
3784        assert_eq!(out.len(), 3);
3785        assert!((out[0] - 9.0).abs() < 1e-4);
3786        assert!((out[1] - (-3.0)).abs() < 1e-4);
3787        assert!((out[2] - 1.0).abs() < 1e-4);
3788    }
3789
3790    #[test]
3791    fn mla_attention_every_head_gets_its_own_kv_no_grouping() {
3792        // Unlike GQA, MLA has no shared-kv-head grouping: with 2 heads and
3793        // 2 cached kv-head-slots, head 0 must only ever see kv slot 0 and
3794        // head 1 only kv slot 1.
3795        let qk_head_dim = 2;
3796        let v_head_dim = 2;
3797        let q = vec![1.0, 0.0, 1.0, 0.0]; // 2 heads
3798        let k_cache = vec![1.0, 0.0, 1.0, 0.0]; // seq_len=1, 2 heads
3799        let v_cache = vec![100.0, 100.0, 200.0, 200.0];
3800        let out = causal_mla_attention(&q, &k_cache, &v_cache, 2, qk_head_dim, v_head_dim, 1);
3801        assert_eq!(&out[0..2], &[100.0, 100.0][..]);
3802        assert_eq!(&out[2..4], &[200.0, 200.0][..]);
3803    }
3804
3805    #[test]
3806    fn lightning_indexer_topk_keeps_all_positions_when_top_k_covers_them() {
3807        let indexer_q = vec![vec![1.0, 0.0]];
3808        let indexer_keys = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.1, 0.9]];
3809        let indexer_weights = vec![1.0];
3810        let kept = lightning_indexer_topk(&indexer_q, &indexer_keys, &indexer_weights, 10);
3811        assert_eq!(kept, vec![0, 1, 2]);
3812    }
3813
3814    #[test]
3815    fn lightning_indexer_topk_selects_highest_scoring_positions() {
3816        // Query aligned with key 0 (dot=1.0), partially with key 2 (dot=0.9),
3817        // orthogonal to key 1 (dot=0.0, relu'd score 0). Top-2 must be {0, 2}.
3818        let indexer_q = vec![vec![1.0, 0.0]];
3819        let indexer_keys = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![0.9, 0.1]];
3820        let indexer_weights = vec![1.0];
3821        let kept = lightning_indexer_topk(&indexer_q, &indexer_keys, &indexer_weights, 2);
3822        assert_eq!(kept, vec![0, 2]);
3823    }
3824
3825    #[test]
3826    fn lightning_indexer_topk_relu_zeroes_negative_dot_products() {
3827        // Key 1's dot product with the query is negative; ReLU floors its
3828        // score at 0, so it must lose to key 0 (positive) even at top_k=1.
3829        let indexer_q = vec![vec![1.0, 0.0]];
3830        let indexer_keys = vec![vec![0.3, 0.0], vec![-1.0, 0.0]];
3831        let indexer_weights = vec![1.0];
3832        let kept = lightning_indexer_topk(&indexer_q, &indexer_keys, &indexer_weights, 1);
3833        assert_eq!(kept, vec![0]);
3834    }
3835
3836    #[test]
3837    fn mla_attention_sparse_with_all_positions_visible_matches_full_causal() {
3838        let qk_head_dim = 3;
3839        let v_head_dim = 2;
3840        let seq_len = 4;
3841        let n_heads = 2;
3842        let q: Vec<f32> = (0..n_heads * qk_head_dim).map(|i| i as f32 * 0.1).collect();
3843        let k_cache: Vec<f32> = (0..seq_len * n_heads * qk_head_dim)
3844            .map(|i| (i as f32 * 0.05).sin())
3845            .collect();
3846        let v_cache: Vec<f32> = (0..seq_len * n_heads * v_head_dim)
3847            .map(|i| (i as f32 * 0.05).cos())
3848            .collect();
3849
3850        let full = causal_mla_attention(
3851            &q,
3852            &k_cache,
3853            &v_cache,
3854            n_heads,
3855            qk_head_dim,
3856            v_head_dim,
3857            seq_len,
3858        );
3859        let visible: Vec<usize> = (0..seq_len).collect();
3860        let sparse = causal_mla_attention_sparse(
3861            &q,
3862            &k_cache,
3863            &v_cache,
3864            n_heads,
3865            qk_head_dim,
3866            v_head_dim,
3867            seq_len,
3868            &visible,
3869        );
3870
3871        assert_eq!(full.len(), sparse.len());
3872        for (a, b) in full.iter().zip(sparse.iter()) {
3873            assert!((a - b).abs() < 1e-6, "full={a} sparse={b}");
3874        }
3875    }
3876
3877    #[test]
3878    fn mla_attention_sparse_ignores_positions_outside_visible_set() {
3879        // Only position 0 is visible; a wildly different value at position 1
3880        // must have zero influence on the output.
3881        let qk_head_dim = 2;
3882        let v_head_dim = 1;
3883        let q = vec![1.0, 0.0];
3884        let k_cache = vec![1.0, 0.0, 1.0, 0.0]; // seq_len=2, identical keys
3885        let v_cache = vec![5.0, 999.0]; // position 0 -> 5.0, position 1 -> 999.0
3886        let out = causal_mla_attention_sparse(
3887            &q,
3888            &k_cache,
3889            &v_cache,
3890            1,
3891            qk_head_dim,
3892            v_head_dim,
3893            2,
3894            &[0],
3895        );
3896        assert_eq!(out.len(), 1);
3897        assert!((out[0] - 5.0).abs() < 1e-6);
3898    }
3899
3900    #[test]
3901    fn attn_logit_softcap_changes_output_vs_uncapped() {
3902        // Softcap must change the attended output relative to the uncapped
3903        // path (and must not be a no-op identity for large scores).
3904        let n_heads = 2;
3905        let n_kv_heads = 1;
3906        let head_dim = 4;
3907        let seq_len = 3;
3908        let q: Vec<f32> = (0..n_heads * head_dim)
3909            .map(|i| (i as f32 + 1.0) * 2.5)
3910            .collect();
3911        let k: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3912            .map(|i| (i as f32 * 0.7).sin() * 3.0)
3913            .collect();
3914        let v: Vec<f32> = (0..seq_len * n_kv_heads * head_dim)
3915            .map(|i| (i as f32 * 0.3).cos())
3916            .collect();
3917        let plain = causal_gqa_attention(&q, &k, &v, n_heads, n_kv_heads, head_dim, seq_len);
3918        let capped = causal_gqa_attention_softcap(
3919            &q,
3920            &k,
3921            &v,
3922            n_heads,
3923            n_kv_heads,
3924            head_dim,
3925            seq_len,
3926            Some(30.0),
3927        );
3928        assert_eq!(plain.len(), capped.len());
3929        let differs = plain
3930            .iter()
3931            .zip(capped.iter())
3932            .any(|(a, b)| (a - b).abs() > 1e-5);
3933        assert!(differs, "softcap must change attention output");
3934        // Softcap None / <=0 must match the uncapped path.
3935        let none =
3936            causal_gqa_attention_softcap(&q, &k, &v, n_heads, n_kv_heads, head_dim, seq_len, None);
3937        for (a, b) in plain.iter().zip(none.iter()) {
3938            assert!((a - b).abs() < 1e-6);
3939        }
3940    }
3941}