Skip to main content

cortiq_engine/
fcd_ops.rs

1//! FCD polish — hand-rolled forward/backward operators.
2//!
3//! The training graph is FIXED (docs/RUST_FCD.md), so there is no
4//! autograd and no tape: every operator here is a (forward, backward)
5//! pair written out by hand, llm.c style. Ops are generic over the
6//! minimal `Fp` float trait so the SAME code that trains in f32 is
7//! gradchecked in f64 against central finite differences
8//! (tests/fcd_gradcheck.rs, rel err < 1e-3).
9//!
10//! Conventions:
11//! - all matrices are row-major; weight matrices use the runtime layout
12//!   `[out_dim, in_dim]` (a matvec is out[o] = dot(w_row_o, x));
13//! - every backward ACCUMULATES into its output gradient buffers
14//!   (`+=`) — callers zero them once per step, residual branches then
15//!   just add up naturally;
16//! - the attention-weight ops (`attn_head_*`, `nystrom_head_*`) are
17//!   meant to run in f64: the certified CPU probe computed the T×T
18//!   weight matrices in f64, which also makes raw `exp(±40)` safe with
19//!   no flash-shift bookkeeping.
20
21use crate::pool::Pool;
22
23// ─────────────────────────── float trait ───────────────────────────
24
25/// Minimal float abstraction: just enough for the fixed graph. Not a
26/// general numeric tower — two impls (f32/f64), no external crates.
27pub trait Fp:
28    Copy
29    + PartialOrd
30    + core::ops::Add<Output = Self>
31    + core::ops::Sub<Output = Self>
32    + core::ops::Mul<Output = Self>
33    + core::ops::Div<Output = Self>
34    + core::ops::Neg<Output = Self>
35    + core::ops::AddAssign
36    + core::ops::MulAssign
37    + Send
38    + Sync
39    + 'static
40{
41    const ZERO: Self;
42    const ONE: Self;
43    fn exp(self) -> Self;
44    fn sqrt(self) -> Self;
45    fn maxf(self, o: Self) -> Self;
46    fn fromf(x: f64) -> Self;
47    fn f64(self) -> f64;
48}
49
50impl Fp for f32 {
51    const ZERO: Self = 0.0;
52    const ONE: Self = 1.0;
53    #[inline]
54    fn exp(self) -> Self {
55        f32::exp(self)
56    }
57    #[inline]
58    fn sqrt(self) -> Self {
59        f32::sqrt(self)
60    }
61    #[inline]
62    fn maxf(self, o: Self) -> Self {
63        f32::max(self, o)
64    }
65    #[inline]
66    fn fromf(x: f64) -> Self {
67        x as f32
68    }
69    #[inline]
70    fn f64(self) -> f64 {
71        self as f64
72    }
73}
74
75impl Fp for f64 {
76    const ZERO: Self = 0.0;
77    const ONE: Self = 1.0;
78    #[inline]
79    fn exp(self) -> Self {
80        f64::exp(self)
81    }
82    #[inline]
83    fn sqrt(self) -> Self {
84        f64::sqrt(self)
85    }
86    #[inline]
87    fn maxf(self, o: Self) -> Self {
88        f64::max(self, o)
89    }
90    #[inline]
91    fn fromf(x: f64) -> Self {
92        x
93    }
94    #[inline]
95    fn f64(self) -> f64 {
96        self
97    }
98}
99
100#[inline]
101/// Eight independent accumulators: a single-chain float sum cannot be
102/// reassociated by the compiler, so the one-accumulator form ran scalar
103/// and made the host attention of a bake (t² dots per head) its slowest
104/// link. The lanes vectorize; the summation order is fixed and
105/// deterministic.
106fn dot<F: Fp>(a: &[F], b: &[F]) -> F {
107    let mut acc = [F::ZERO; 8];
108    let mut ca = a.chunks_exact(8);
109    let mut cb = b.chunks_exact(8);
110    for (x, y) in (&mut ca).zip(&mut cb) {
111        for l in 0..8 {
112            acc[l] += x[l] * y[l];
113        }
114    }
115    let mut s = F::ZERO;
116    for (x, y) in ca.remainder().iter().zip(cb.remainder()) {
117        s += *x * *y;
118    }
119    ((acc[0] + acc[1]) + (acc[2] + acc[3])) + ((acc[4] + acc[5]) + (acc[6] + acc[7])) + s
120}
121
122// ─────────────────────── generic matmul (nt) ───────────────────────
123
124/// y[n,m] = x[n,k] · w[m,k]ᵀ (serial reference; the f32 hot path is
125/// `gemm_nt` below — same math, blocked + pooled).
126pub fn matmul_nt<F: Fp>(x: &[F], w: &[F], y: &mut [F], n: usize, k: usize, m: usize) {
127    for i in 0..n {
128        let xr = &x[i * k..(i + 1) * k];
129        for o in 0..m {
130            y[i * m + o] = dot(xr, &w[o * k..(o + 1) * k]);
131        }
132    }
133}
134
135/// dX += dY[n,m] · W[m,k] — the input-gradient half of matmul_nt.
136pub fn matmul_nt_dx<F: Fp>(dy: &[F], w: &[F], dx: &mut [F], n: usize, k: usize, m: usize) {
137    for i in 0..n {
138        let dxr = &mut dx[i * k..(i + 1) * k];
139        for o in 0..m {
140            let g = dy[i * m + o];
141            for (d, wv) in dxr.iter_mut().zip(&w[o * k..(o + 1) * k]) {
142                *d += g * *wv;
143            }
144        }
145    }
146}
147
148/// dW += dYᵀ[m,n] · X[n,k] — the weight-gradient half of matmul_nt.
149pub fn matmul_nt_dw<F: Fp>(dy: &[F], x: &[F], dw: &mut [F], n: usize, k: usize, m: usize) {
150    for i in 0..n {
151        let xr = &x[i * k..(i + 1) * k];
152        for o in 0..m {
153            let g = dy[i * m + o];
154            for (d, xv) in dw[o * k..(o + 1) * k].iter_mut().zip(xr) {
155                *d += g * *xv;
156            }
157        }
158    }
159}
160
161// ───────────────────── pooled f32 GEMM hot path ─────────────────────
162
163/// Row block: the block's X stays cache-resident while W streams once
164/// per block (a per-row W stream would move gigabytes per matmul).
165const GEMM_BLOCK: usize = 128;
166
167/// Pointer wrapper for disjoint parallel writes (same pattern as the
168/// pipeline's scatter — workers touch disjoint index ranges).
169struct SendMut<T>(*mut T);
170unsafe impl<T> Send for SendMut<T> {}
171unsafe impl<T> Sync for SendMut<T> {}
172impl<T> SendMut<T> {
173    #[inline]
174    // Deliberate unsynchronized scatter: pool workers write disjoint
175    // ranges in parallel, so returning `&mut` from `&self` is
176    // intentional here (same pattern as the pipeline's SendMut).
177    #[allow(clippy::mut_from_ref)]
178    unsafe fn slice(&self, off: usize, len: usize) -> &mut [T] {
179        unsafe { std::slice::from_raw_parts_mut(self.0.add(off), len) }
180    }
181}
182
183/// y[n,m] = x[n,k] · w[m,k]ᵀ — parallel over row blocks; bit-identical
184/// to `matmul_nt` (disjoint rows, same dot kernel regrouped by NEON).
185
186/// Accelerate CBLAS fast path for the training GEMMs (macOS): the
187/// naive pooled kernels below stay as the portable/reference path.
188#[cfg(target_os = "macos")]
189mod accel {
190    #[link(name = "Accelerate", kind = "framework")]
191    unsafe extern "C" {
192        pub fn cblas_sgemm(
193            order: i32,
194            ta: i32,
195            tb: i32,
196            m: i32,
197            n: i32,
198            k: i32,
199            alpha: f32,
200            a: *const f32,
201            lda: i32,
202            b: *const f32,
203            ldb: i32,
204            beta: f32,
205            c: *mut f32,
206            ldc: i32,
207        );
208    }
209
210    pub fn on() -> bool {
211        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
212        *ON.get_or_init(|| std::env::var("CMF_ACCEL").map(|v| v != "0").unwrap_or(true))
213    }
214}
215
216/// Is the 4x4 f32 micro-kernel available here?
217#[cfg(target_arch = "x86_64")]
218fn gemm_avx512() -> bool {
219    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
220    *ON.get_or_init(|| {
221        std::env::var("CMF_GEMM_BLOCK4")
222            .map(|v| v != "0")
223            .unwrap_or(true)
224            && std::arch::is_x86_feature_detected!("avx512f")
225    })
226}
227
228/// A 4x4 block of `y = x · wᵀ`, sixteen accumulators in `zmm` registers.
229/// Each 16-float step loads four rows of each side and issues sixteen
230/// fused multiply-adds — two per load, where a dot product manages one
231/// per two.
232#[cfg(target_arch = "x86_64")]
233#[target_feature(enable = "avx512f")]
234unsafe fn gemm_block4x4_avx512(
235    x: &[f32],
236    w: &[f32],
237    i0: usize,
238    o0: usize,
239    k: usize,
240) -> [[f32; 4]; 4] {
241    // SAFETY: caller guarantees i0 + 4 and o0 + 4 rows exist and the ISA.
242    unsafe {
243        use core::arch::x86_64::*;
244        let mut acc = [[_mm512_setzero_ps(); 4]; 4];
245        let xp = x.as_ptr().add(i0 * k);
246        let wp = w.as_ptr().add(o0 * k);
247        let mut c = 0usize;
248        while c + 16 <= k {
249            let xv = [
250                _mm512_loadu_ps(xp.add(c)),
251                _mm512_loadu_ps(xp.add(k + c)),
252                _mm512_loadu_ps(xp.add(2 * k + c)),
253                _mm512_loadu_ps(xp.add(3 * k + c)),
254            ];
255            let wv = [
256                _mm512_loadu_ps(wp.add(c)),
257                _mm512_loadu_ps(wp.add(k + c)),
258                _mm512_loadu_ps(wp.add(2 * k + c)),
259                _mm512_loadu_ps(wp.add(3 * k + c)),
260            ];
261            for a in 0..4 {
262                for b in 0..4 {
263                    acc[a][b] = _mm512_fmadd_ps(xv[a], wv[b], acc[a][b]);
264                }
265            }
266            c += 16;
267        }
268        let mut out = [[0f32; 4]; 4];
269        for a in 0..4 {
270            for b in 0..4 {
271                out[a][b] = _mm512_reduce_add_ps(acc[a][b]);
272            }
273        }
274        // The tail, scalar: k is a channel count times a kernel area and
275        // is not always a multiple of sixteen.
276        while c < k {
277            for a in 0..4 {
278                let xe = *xp.add(a * k + c);
279                for b in 0..4 {
280                    out[a][b] += xe * *wp.add(b * k + c);
281                }
282            }
283            c += 1;
284        }
285        out
286    }
287}
288
289/// `y = x · wᵀ`, row-major.
290///
291/// A discrete card can take the big forwards whole — the weight side is
292/// cached on-device after first use, so a 360-step run uploads each
293/// matrix once. It does not always WIN, though: this call also carries
294/// attention's per-head QKᵀ and AV and the video decoder's projections,
295/// where the round trip cost more than the work. So the device arm goes
296/// through the same probe every other op class uses instead of taking
297/// the job on sight. `CMF_BAKE_GPU=0` keeps the CPU path outright.
298
299/// Tiny drop-guard so every return path of gemm_nt lands in the GEMM
300/// phase counter — the function has four exits (two GPU, two CPU). The
301/// per-shape bucket lives here too: the first version measured the
302/// shape at ENTRY and reported 0.0 ms for every shape while the total
303/// said 8.9 s.
304struct GemmGuard(std::time::Instant, usize, usize, usize);
305impl Drop for GemmGuard {
306    fn drop(&mut self) {
307        crate::fcd::prof::add(&crate::fcd::prof::GEMM, self.0);
308        crate::fcd::prof::gemm_shape(self.1, self.2, self.3, self.0);
309    }
310}
311fn scopeguard_gemm(t: std::time::Instant, n: usize, k: usize, m: usize) -> GemmGuard {
312    GemmGuard(t, n, k, m)
313}
314
315pub fn gemm_nt(
316    x: &[f32],
317    w: &[f32],
318    y: &mut [f32],
319    n: usize,
320    k: usize,
321    m: usize,
322    pool: Option<&Pool>,
323) {
324    let _t_gemm = std::time::Instant::now();
325    crate::fcd::prof::GEMM_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
326    let _guard = scopeguard_gemm(_t_gemm, n, k, m);
327    // A thin output (m < 64: a per-head gate projection) never fills a
328    // tensor-core tile and only costs the round trip — host.
329    #[cfg(feature = "gpu")]
330    if n * k * m >= (1 << 22) && m >= 64 && crate::gpu::enabled_here() {
331        let t0 = std::time::Instant::now();
332        match crate::gpu::probe_arm(crate::gpu::OpClass::GemmNt) {
333            crate::gpu::ProbeArm::Gpu => {
334                if crate::gpu_wgpu::gemm_nt_f32(x, w, y, n, k, m) {
335                    crate::gpu::probe_record(crate::gpu::OpClass::GemmNt, true, t0.elapsed());
336                    return;
337                }
338            }
339            crate::gpu::ProbeArm::CpuTimed => {
340                crate::gpu::cpu_scope(|| gemm_nt_cpu(x, w, y, n, k, m, pool));
341                crate::gpu::probe_record(crate::gpu::OpClass::GemmNt, false, t0.elapsed());
342                return;
343            }
344            crate::gpu::ProbeArm::Cpu => {}
345        }
346    }
347    gemm_nt_cpu(x, w, y, n, k, m, pool)
348}
349
350/// `gemm_nt` on the host, whatever the device state (the plain f32 GEMM;
351/// the device arm of `gemm_nt` is tf32-class on NVIDIA).
352///
353/// `y[n,m] = x[n,k] · w[m,k]ᵀ`, all row-major, `y[..n·m]` overwritten. This
354/// never initialises, probes or dispatches to a GPU backend, whatever the
355/// size: it is the entry point for callers that need f32 host arithmetic
356/// (the decision encoder). On macOS it is Accelerate `cblas_sgemm` for
357/// `n·k·m ≥ 2^18` (`CMF_ACCEL=0` turns that off); otherwise the pooled
358/// dot kernels, where `pool` only splits disjoint row blocks of `y`.
359///
360/// Only the leading `n·k`, `m·k` and `n·m` elements of `x`, `w` and `y` are
361/// used (callers pass scratch buffers sized for their largest batch); it
362/// panics if a buffer is shorter than that, because the kernels address
363/// the buffers through raw pointers.
364pub fn gemm_nt_host(
365    x: &[f32],
366    w: &[f32],
367    y: &mut [f32],
368    n: usize,
369    k: usize,
370    m: usize,
371    pool: Option<&Pool>,
372) {
373    assert!(
374        x.len() >= n * k,
375        "gemm_nt_host: x has {} < n·k = {}",
376        x.len(),
377        n * k
378    );
379    assert!(
380        w.len() >= m * k,
381        "gemm_nt_host: w has {} < m·k = {}",
382        w.len(),
383        m * k
384    );
385    assert!(
386        y.len() >= n * m,
387        "gemm_nt_host: y has {} < n·m = {}",
388        y.len(),
389        n * m
390    );
391    gemm_nt_cpu(&x[..n * k], &w[..m * k], &mut y[..n * m], n, k, m, pool)
392}
393
394fn gemm_nt_cpu(
395    x: &[f32],
396    w: &[f32],
397    y: &mut [f32],
398    n: usize,
399    k: usize,
400    m: usize,
401    pool: Option<&Pool>,
402) {
403    debug_assert_eq!(x.len(), n * k);
404    debug_assert_eq!(w.len(), m * k);
405    debug_assert_eq!(y.len(), n * m);
406    #[cfg(target_os = "macos")]
407    if accel::on() && n * k * m >= 1 << 18 {
408        // Y = X · Wᵀ (row-major).
409        unsafe {
410            accel::cblas_sgemm(
411                101,
412                111,
413                112,
414                n as i32,
415                m as i32,
416                k as i32,
417                1.0,
418                x.as_ptr(),
419                k as i32,
420                w.as_ptr(),
421                k as i32,
422                0.0,
423                y.as_mut_ptr(),
424                m as i32,
425            );
426        }
427        return;
428    }
429    let nb = n.div_ceil(GEMM_BLOCK);
430    let block = |r0: usize, r1: usize, y: &mut [f32]| {
431        // Four rows by four columns at a time, in vector registers: one
432        // dot product per output spends two loads on one multiply and
433        // re-reads the whole activation row for every column, while a 4x4
434        // block reads four of each and does sixteen multiplies. This is
435        // the VAE decoder's inner loop — 79 s of a 512x512 render on 48
436        // threads before it.
437        //
438        // Written with intrinsics, not by hand in scalars: the same
439        // blocking expressed as `for l in 0..8 { t += x[l] * w[l] }` lost
440        // the vectorisation that `dot_f32` already had and ran the whole
441        // render from 90 s to 168.
442        let mut o = 0usize;
443        #[cfg(target_arch = "x86_64")]
444        if gemm_avx512() {
445            while o + 4 <= m {
446                let mut i = r0;
447                while i + 4 <= r1 {
448                    // SAFETY: the ISA is checked above and every access
449                    // below stays inside the 4x4 block of x, w and y.
450                    let acc = unsafe { gemm_block4x4_avx512(x, w, i, o, k) };
451                    for (di, ai) in acc.iter().enumerate() {
452                        for (dj, v) in ai.iter().enumerate() {
453                            y[(i - r0 + di) * m + o + dj] = *v;
454                        }
455                    }
456                    i += 4;
457                }
458                for i in i..r1 {
459                    let xr = &x[i * k..(i + 1) * k];
460                    for dj in 0..4 {
461                        let wr = &w[(o + dj) * k..(o + dj + 1) * k];
462                        y[(i - r0) * m + o + dj] = crate::attention::dot_f32(xr, wr);
463                    }
464                }
465                o += 4;
466            }
467        }
468        for o in o..m {
469            let wr = &w[o * k..(o + 1) * k];
470            for i in r0..r1 {
471                y[(i - r0) * m + o] = crate::attention::dot_f32(&x[i * k..(i + 1) * k], wr);
472            }
473        }
474    };
475    match pool {
476        Some(p) if nb > 1 => {
477            let yp = SendMut(y.as_mut_ptr());
478            p.run(&|widx, nw| {
479                for bi in (widx..nb).step_by(nw) {
480                    let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
481                    // SAFETY: blocks are disjoint row ranges of y.
482                    let ys = unsafe { yp.slice(r0 * m, (r1 - r0) * m) };
483                    block(r0, r1, ys);
484                }
485            });
486        }
487        _ => {
488            for bi in 0..nb {
489                let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
490                block(r0, r1, &mut y[r0 * m..r1 * m]);
491            }
492        }
493    }
494}
495
496/// dX += dY[n,m] · W[m,k] — parallel over row blocks (disjoint dX rows).
497pub fn gemm_dx(
498    dy: &[f32],
499    w: &[f32],
500    dx: &mut [f32],
501    n: usize,
502    k: usize,
503    m: usize,
504    pool: Option<&Pool>,
505) {
506    let _t = std::time::Instant::now();
507    crate::fcd::prof::GEMM_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
508    let _guard = scopeguard_gemm(_t, n, k, m);
509    debug_assert_eq!(dy.len(), n * m);
510    debug_assert_eq!(w.len(), m * k);
511    debug_assert_eq!(dx.len(), n * k);
512    // The card takes the backward too: phase A of a bake is three of
513    // these per layer and nothing else, so this is what makes a bake
514    // GPU work rather than a CPU vigil. CMF_BAKE_GPU=0 opts out.
515    #[cfg(feature = "gpu")]
516    if crate::gpu_wgpu::gemm_dx_f32(dy, w, dx, n, k, m) {
517        return;
518    }
519    #[cfg(target_os = "macos")]
520    if accel::on() && n * k * m >= 1 << 18 {
521        // dX += dY · W (row-major, beta = 1 accumulates).
522        unsafe {
523            accel::cblas_sgemm(
524                101,
525                111,
526                111,
527                n as i32,
528                k as i32,
529                m as i32,
530                1.0,
531                dy.as_ptr(),
532                m as i32,
533                w.as_ptr(),
534                k as i32,
535                1.0,
536                dx.as_mut_ptr(),
537                k as i32,
538            );
539        }
540        return;
541    }
542    let nb = n.div_ceil(GEMM_BLOCK);
543    let block = |r0: usize, r1: usize, dxs: &mut [f32]| {
544        for o in 0..m {
545            let wr = &w[o * k..(o + 1) * k];
546            for i in r0..r1 {
547                let g = dy[i * m + o];
548                if g != 0.0 {
549                    crate::attention::axpy_f32(&mut dxs[(i - r0) * k..(i - r0 + 1) * k], wr, g);
550                }
551            }
552        }
553    };
554    match pool {
555        Some(p) if nb > 1 => {
556            let dxp = SendMut(dx.as_mut_ptr());
557            p.run(&|widx, nw| {
558                for bi in (widx..nb).step_by(nw) {
559                    let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
560                    // SAFETY: blocks are disjoint row ranges of dx.
561                    let dxs = unsafe { dxp.slice(r0 * k, (r1 - r0) * k) };
562                    block(r0, r1, dxs);
563                }
564            });
565        }
566        _ => {
567            for bi in 0..nb {
568                let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
569                block(r0, r1, &mut dx[r0 * k..r1 * k]);
570            }
571        }
572    }
573}
574
575/// dW += dYᵀ · X — parallel over dW ROW ranges (each worker owns a
576/// disjoint slice of output neurons; X is shared read-only).
577pub fn gemm_dw(
578    dy: &[f32],
579    x: &[f32],
580    dw: &mut [f32],
581    n: usize,
582    k: usize,
583    m: usize,
584    pool: Option<&Pool>,
585) {
586    let _t = std::time::Instant::now();
587    crate::fcd::prof::GEMM_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
588    let _guard = scopeguard_gemm(_t, n, k, m);
589    debug_assert_eq!(dy.len(), n * m);
590    debug_assert_eq!(x.len(), n * k);
591    debug_assert_eq!(dw.len(), m * k);
592    #[cfg(target_os = "macos")]
593    if accel::on() && n * k * m >= 1 << 18 {
594        // dW += dYᵀ · X (row-major, beta = 1 accumulates).
595        unsafe {
596            accel::cblas_sgemm(
597                101,
598                112,
599                111,
600                m as i32,
601                k as i32,
602                n as i32,
603                1.0,
604                dy.as_ptr(),
605                m as i32,
606                x.as_ptr(),
607                k as i32,
608                1.0,
609                dw.as_mut_ptr(),
610                k as i32,
611            );
612        }
613        return;
614    }
615    let range = |o0: usize, o1: usize, dws: &mut [f32]| {
616        // i-blocked so the X block stays in cache across the o loop.
617        let nb = n.div_ceil(GEMM_BLOCK);
618        for bi in 0..nb {
619            let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
620            for o in o0..o1 {
621                let dwr = &mut dws[(o - o0) * k..(o - o0 + 1) * k];
622                for i in r0..r1 {
623                    let g = dy[i * m + o];
624                    if g != 0.0 {
625                        crate::attention::axpy_f32(dwr, &x[i * k..(i + 1) * k], g);
626                    }
627                }
628            }
629        }
630    };
631    match pool {
632        Some(p) if m >= 8 => {
633            let dwp = SendMut(dw.as_mut_ptr());
634            p.run(&|widx, nw| {
635                let (o0, o1) = (widx * m / nw, (widx + 1) * m / nw);
636                if o0 < o1 {
637                    // SAFETY: workers own disjoint dW row ranges.
638                    let dws = unsafe { dwp.slice(o0 * k, (o1 - o0) * k) };
639                    range(o0, o1, dws);
640                }
641            });
642        }
643        _ => range(0, m, dw),
644    }
645}
646
647// ───────────────────────────── SiLU ─────────────────────────────
648
649#[inline]
650pub fn silu<F: Fp>(x: F) -> F {
651    x / (F::ONE + (-x).exp())
652}
653
654/// d silu / dx = σ(x)·(1 + x·(1−σ(x))).
655#[inline]
656pub fn silu_bwd<F: Fp>(x: F) -> F {
657    let s = F::ONE / (F::ONE + (-x).exp());
658    s * (F::ONE + x * (F::ONE - s))
659}
660
661/// erf by Abramowitz–Stegun 7.1.26 (|error| ≤ 1.5e-7) — the formula the
662/// runtime (`inference::erf_f32`) and the GPU kernels evaluate, so the
663/// bake differentiates the activation the model will actually run.
664#[inline]
665pub fn erf_as<F: Fp>(x: F) -> F {
666    let a = x.maxf(-x);
667    let t = F::ONE / (F::ONE + F::fromf(0.327_591_1) * a);
668    let poly = ((((F::fromf(1.061_405_4) * t - F::fromf(1.453_152_1)) * t
669        + F::fromf(1.421_413_8))
670        * t
671        - F::fromf(0.284_496_74))
672        * t
673        + F::fromf(0.254_829_6))
674        * t;
675    let y = F::ONE - poly * (-(a * a)).exp();
676    if x < F::ZERO { -y } else { y }
677}
678
679/// Exact (erf) GELU, `0.5·x·(1 + erf(x/√2))` — HF `hidden_act = "gelu"`
680/// (Spark-X2.5).
681#[inline]
682pub fn gelu_erf<F: Fp>(x: F) -> F {
683    F::fromf(0.5) * x * (F::ONE + erf_as(x * F::fromf(std::f64::consts::FRAC_1_SQRT_2)))
684}
685
686/// d gelu / dx = Φ(x) + x·φ(x): Φ = ½(1 + erf(x/√2)), φ = exp(−x²/2)/√(2π).
687#[inline]
688pub fn gelu_erf_bwd<F: Fp>(x: F) -> F {
689    let cdf = F::fromf(0.5) * (F::ONE + erf_as(x * F::fromf(std::f64::consts::FRAC_1_SQRT_2)));
690    let pdf = (-(x * x) * F::fromf(0.5)).exp() * F::fromf(0.398_942_280_401_432_7);
691    cdf + x * pdf
692}
693
694/// The gated FFN's gate-branch activation: `act(g)·u` feeds down_proj.
695/// One enum, so every forward and every hand-rolled backward in the
696/// trainer names the same function instead of spelling SiLU inline.
697#[derive(Clone, Copy, Debug, PartialEq, Eq)]
698pub enum FfnAct {
699    Silu,
700    /// Exact erf GELU (Spark-X2.5).
701    GeluErf,
702}
703
704impl FfnAct {
705    /// `None` for an activation the backward does not differentiate yet.
706    pub fn from_arch(name: &str) -> Option<Self> {
707        match name {
708            "silu" => Some(Self::Silu),
709            "gelu" => Some(Self::GeluErf),
710            _ => None,
711        }
712    }
713
714    #[inline]
715    pub fn fwd<F: Fp>(self, x: F) -> F {
716        match self {
717            Self::Silu => silu(x),
718            Self::GeluErf => gelu_erf(x),
719        }
720    }
721
722    #[inline]
723    pub fn bwd<F: Fp>(self, x: F) -> F {
724        match self {
725            Self::Silu => silu_bwd(x),
726            Self::GeluErf => gelu_erf_bwd(x),
727        }
728    }
729}
730
731// ──────────────────────────── RMSNorm ────────────────────────────
732
733/// RMSNorm over `n` rows of width `d = w.len()`:
734/// Qwen style y = x̂·w, Gemma style y = x̂·(1+w), x̂ = x/√(mean x² + eps).
735/// Sum-of-squares accumulates in f64 (runtime discipline). `inv_out`
736/// stores the per-row 1/rms for the backward.
737pub fn rmsnorm_fwd<F: Fp>(x: &[F], w: &[F], eps: f64, gemma: bool, y: &mut [F], inv_out: &mut [F]) {
738    let d = w.len();
739    let n = x.len() / d;
740    for r in 0..n {
741        let xr = &x[r * d..(r + 1) * d];
742        let mut ss = 0f64;
743        for v in xr {
744            ss += v.f64() * v.f64();
745        }
746        let inv = F::fromf(1.0 / (ss / d as f64 + eps).sqrt());
747        inv_out[r] = inv;
748        let yr = &mut y[r * d..(r + 1) * d];
749        for j in 0..d {
750            let weff = if gemma { F::ONE + w[j] } else { w[j] };
751            yr[j] = xr[j] * inv * weff;
752        }
753    }
754}
755
756/// RMSNorm backward: through-grad into `dx` (+=) and, when the gain is
757/// trainable, gain grad into `dw` (+=). `inv` is the saved 1/rms.
758///
759/// dx_j = inv·w_j·dy_j − x_j·inv³/d · Σ_i dy_i·w_i·x_i
760/// dw_j += dy_j·x_j·inv (identical for both styles: ∂y/∂w = x̂).
761pub fn rmsnorm_bwd<F: Fp>(
762    x: &[F],
763    w: &[F],
764    inv: &[F],
765    dy: &[F],
766    gemma: bool,
767    dx: &mut [F],
768    mut dw: Option<&mut [F]>,
769) {
770    let d = w.len();
771    let n = x.len() / d;
772    for r in 0..n {
773        let xr = &x[r * d..(r + 1) * d];
774        let dyr = &dy[r * d..(r + 1) * d];
775        let iv = inv[r];
776        let mut s = 0f64;
777        for j in 0..d {
778            let weff = if gemma { F::ONE + w[j] } else { w[j] };
779            s += (dyr[j] * weff * xr[j]).f64();
780        }
781        let coef = F::fromf(s / d as f64) * iv * iv * iv;
782        let dxr = &mut dx[r * d..(r + 1) * d];
783        for j in 0..d {
784            let weff = if gemma { F::ONE + w[j] } else { w[j] };
785            dxr[j] += iv * weff * dyr[j] - xr[j] * coef;
786        }
787        if let Some(dwv) = dw.as_deref_mut() {
788            for j in 0..d {
789                dwv[j] += dyr[j] * xr[j] * iv;
790            }
791        }
792    }
793}
794
795// ───────────────────────────── RoPE ─────────────────────────────
796
797/// Rotate the first `2·inv_freq.len()` dims of one head vector in place
798/// (half-split pairing, same convention as `attention::rope_rotate`).
799pub fn rope_fwd<F: Fp>(x: &mut [F], position: usize, inv_freq: &[f64]) {
800    let half = inv_freq.len();
801    for (i, &freq) in inv_freq.iter().enumerate() {
802        let angle = position as f64 * freq;
803        let (sin, cos) = (F::fromf(angle.sin()), F::fromf(angle.cos()));
804        let x0 = x[i];
805        let x1 = x[i + half];
806        x[i] = x0 * cos - x1 * sin;
807        x[i + half] = x0 * sin + x1 * cos;
808    }
809}
810
811/// RoPE through-grad: a rotation's Jacobian is the rotation itself, so
812/// dL/dx = R(−θ)·dL/dy — the inverse rotation, in place.
813pub fn rope_bwd<F: Fp>(dy: &mut [F], position: usize, inv_freq: &[f64]) {
814    let half = inv_freq.len();
815    for (i, &freq) in inv_freq.iter().enumerate() {
816        let angle = position as f64 * freq;
817        let (sin, cos) = (F::fromf(angle.sin()), F::fromf(angle.cos()));
818        let g0 = dy[i];
819        let g1 = dy[i + half];
820        dy[i] = g0 * cos + g1 * sin;
821        dy[i + half] = -g0 * sin + g1 * cos;
822    }
823}
824
825// ─────────────────────── landmark segment means ───────────────────────
826
827/// Contiguous segment means (Nyströmformer landmark recipe), the same
828/// integer split (i·t)/m as `nystrom::seg_means` and the torch probes.
829pub fn seg_means<F: Fp>(x: &[F], t: usize, d: usize, m: usize, out: &mut [F]) {
830    for i in 0..m {
831        let (lo, hi) = (i * t / m, (i + 1) * t / m);
832        let or = &mut out[i * d..(i + 1) * d];
833        for v in or.iter_mut() {
834            *v = F::ZERO;
835        }
836        for j in lo..hi {
837            for c in 0..d {
838                or[c] += x[j * d + c];
839            }
840        }
841        let inv = F::fromf(1.0 / (hi - lo) as f64);
842        for v in or.iter_mut() {
843            *v *= inv;
844        }
845    }
846}
847
848/// Segment-mean backward: the mean is linear, so the landmark grad
849/// scatters back uniformly over its segment (dx += dl/seg_len).
850pub fn seg_means_bwd<F: Fp>(dl: &[F], t: usize, d: usize, m: usize, dx: &mut [F]) {
851    for i in 0..m {
852        let (lo, hi) = (i * t / m, (i + 1) * t / m);
853        let inv = F::fromf(1.0 / (hi - lo) as f64);
854        let dlr = &dl[i * d..(i + 1) * d];
855        for j in lo..hi {
856            for c in 0..d {
857                dx[j * d + c] += dlr[c] * inv;
858            }
859        }
860    }
861}
862
863// ────────────────── exact causal softmax attention ──────────────────
864
865/// Exact per-head causal attention: out[t] = softmax(q_t·Kᵀ/√d)·V over
866/// j ≤ t. `q`,`k` are `[t,d]`, `v` is `[t,dv]`, `out` is `[t,dv]`.
867pub fn attn_head_fwd<F: Fp>(
868    q: &[F],
869    k: &[F],
870    v: &[F],
871    t: usize,
872    d: usize,
873    dv: usize,
874    out: &mut [F],
875) {
876    attn_head_fwd_window(q, k, v, t, d, dv, None, out)
877}
878
879/// `attn_head_fwd` with a sliding window: query t sees the keys
880/// `j ∈ [t+1−w, t]` (w keys, itself included) — the runtime's
881/// `first = stored − w` rule (`attention::attend_all_heads`), so a
882/// bake's forward on a Gemma-3 / Spark-X2.5 local layer matches what
883/// the engine serves. `None` is full causal.
884#[allow(clippy::needless_range_loop, clippy::too_many_arguments)] // row[j] pairs with k-row j — indices are the clearer form
885pub fn attn_head_fwd_window<F: Fp>(
886    q: &[F],
887    k: &[F],
888    v: &[F],
889    t: usize,
890    d: usize,
891    dv: usize,
892    window: Option<usize>,
893    out: &mut [F],
894) {
895    let scale = F::fromf(1.0 / (d as f64).sqrt());
896    let mut row = vec![F::ZERO; t];
897    for ti in 0..t {
898        let first = window.map(|w| (ti + 1).saturating_sub(w)).unwrap_or(0);
899        let qr = &q[ti * d..(ti + 1) * d];
900        let mut mx = F::fromf(f64::NEG_INFINITY);
901        for j in first..=ti {
902            let s = dot(qr, &k[j * d..(j + 1) * d]) * scale;
903            row[j] = s;
904            mx = mx.maxf(s);
905        }
906        let mut den = F::ZERO;
907        for j in first..=ti {
908            row[j] = (row[j] - mx).exp();
909            den += row[j];
910        }
911        let or = &mut out[ti * dv..(ti + 1) * dv];
912        for o in or.iter_mut() {
913            *o = F::ZERO;
914        }
915        for j in first..=ti {
916            let p = row[j] / den;
917            for (o, vv) in or.iter_mut().zip(&v[j * dv..(j + 1) * dv]) {
918                *o += p * *vv;
919            }
920        }
921    }
922}
923
924/// Exact-attention backward (probs recomputed row by row — the trainer
925/// is layer-checkpointed, nothing is stored). Standard softmax chain:
926/// ds_j = p_j·(dp_j − Σ p·dp), dp_j = dout·v_j.
927#[allow(clippy::too_many_arguments)]
928pub fn attn_head_bwd<F: Fp>(
929    q: &[F],
930    k: &[F],
931    v: &[F],
932    dout: &[F],
933    t: usize,
934    d: usize,
935    dv: usize,
936    dq: &mut [F],
937    dk: &mut [F],
938    dvv: &mut [F],
939) {
940    attn_head_bwd_window(q, k, v, dout, t, d, dv, None, dq, dk, dvv)
941}
942
943/// `attn_head_bwd` under the sliding window of `attn_head_fwd_window`.
944#[allow(clippy::too_many_arguments, clippy::needless_range_loop)]
945pub fn attn_head_bwd_window<F: Fp>(
946    q: &[F],
947    k: &[F],
948    v: &[F],
949    dout: &[F],
950    t: usize,
951    d: usize,
952    dv: usize,
953    window: Option<usize>,
954    dq: &mut [F],
955    dk: &mut [F],
956    dvv: &mut [F],
957) {
958    let scale = F::fromf(1.0 / (d as f64).sqrt());
959    let mut row = vec![F::ZERO; t];
960    for ti in 0..t {
961        let first = window.map(|w| (ti + 1).saturating_sub(w)).unwrap_or(0);
962        let qr = &q[ti * d..(ti + 1) * d];
963        let mut mx = F::fromf(f64::NEG_INFINITY);
964        for j in first..=ti {
965            let s = dot(qr, &k[j * d..(j + 1) * d]) * scale;
966            row[j] = s;
967            mx = mx.maxf(s);
968        }
969        let mut den = F::ZERO;
970        for j in first..=ti {
971            row[j] = (row[j] - mx).exp();
972            den += row[j];
973        }
974        let dor = &dout[ti * dv..(ti + 1) * dv];
975        // dp_j and the softmax dot Σ p·dp in one pass.
976        let mut pdp = F::ZERO;
977        let mut dp = vec![F::ZERO; ti + 1];
978        for j in first..=ti {
979            let p = row[j] / den;
980            row[j] = p; // row now holds probabilities
981            dp[j] = dot(dor, &v[j * dv..(j + 1) * dv]);
982            pdp += p * dp[j];
983        }
984        let dqr = &mut dq[ti * d..(ti + 1) * d];
985        for j in first..=ti {
986            let p = row[j];
987            // dV
988            for (dvo, o) in dvv[j * dv..(j + 1) * dv].iter_mut().zip(dor) {
989                *dvo += p * *o;
990            }
991            // d logits → dq, dk
992            let ds = p * (dp[j] - pdp) * scale;
993            let kr = &k[j * d..(j + 1) * d];
994            for c in 0..d {
995                dqr[c] += ds * kr[c];
996            }
997            let dkr = &mut dk[j * d..(j + 1) * d];
998            for c in 0..d {
999                dkr[c] += ds * qr[c];
1000            }
1001        }
1002    }
1003}
1004
1005// ─────────────────── Nyström joint attention (matrix form) ───────────────────
1006
1007/// Nyström joint kernel geometry — matches `nystrom::O1Cfg` semantics.
1008#[derive(Clone, Copy, Debug)]
1009pub struct NysCfg {
1010    /// Landmark budget (m_eff = clamp(t_prefill/8, 4, m), as sealed).
1011    pub m: usize,
1012    /// Exact-window width.
1013    pub w: usize,
1014    /// Permanent exact sink keys.
1015    pub sink: usize,
1016    /// Tokens of each training window that run the EXACT prompt pass
1017    /// before the O(1) seal. `None` = half the window.
1018    ///
1019    /// This is not a free knob: it is the train/serve contract. The
1020    /// runtime (`nystrom::NystromState::prefill`) freezes landmarks from
1021    /// the PROMPT ONLY and then serves every later position off that
1022    /// frozen skeleton, so a trainer that seals from the whole window is
1023    /// optimizing a model that is never served. The default mirrors
1024    /// `cortiq ppl --o1`'s own default (`o1_prefill = len/2`), which is
1025    /// the discipline every published o1 quality number was measured
1026    /// with — keep them equal or the polish and the gate disagree.
1027    pub prefill: Option<usize>,
1028}
1029
1030impl NysCfg {
1031    /// Prompt length for a `t`-token window: the seal point. Clamped to
1032    /// [1, t] so a caller cannot ask for a prefix longer than the window.
1033    #[inline]
1034    pub fn prefill_len(&self, t: usize) -> usize {
1035        self.prefill.unwrap_or(t / 2).clamp(1, t)
1036    }
1037}
1038
1039/// Joint-denominator floor (mirrors the reference probe / runtime).
1040const NYS_DEN_EPS: f64 = 1e-30;
1041
1042/// Everything the forward computes that the backward reuses. All T×T
1043/// buffers — call this with F = f64 (raw exp of real logits overflows
1044/// f32; the certified CPU probe ran the skeleton in f64).
1045struct NysGraph<F: Fp> {
1046    m_eff: usize,
1047    /// Seal point: rows < tp are exact (the prompt pass), rows ≥ tp are
1048    /// served off the frozen skeleton.
1049    tp: usize,
1050    q_l: Vec<F>,
1051    k_l: Vec<F>,
1052    mu: Vec<F>,
1053    fu: Vec<F>,
1054    e: Vec<F>,
1055    fumu: Vec<F>,
1056    /// Per row: did the AGGREGATE far denominator survive the guard?
1057    /// False ⇒ this row's far field is dropped entirely (weights and
1058    /// gradient alike) — see `nys_graph`.
1059    ///
1060    /// This replaces the raw skeleton estimate `a`, which the backward
1061    /// used to carry ONLY to evaluate the per-(t,j) clamp's `[a>0]`
1062    /// subgradient mask. The shipped operator gates per ROW, so the mask
1063    /// — and the whole T×T buffer behind it — is gone.
1064    far_keep: Vec<bool>,
1065    /// Final joint weights: exp(lg−c) on near, a·e^{−c} on a kept far
1066    /// field, 0 on a dropped one.
1067    wmat: Vec<F>,
1068    c_row: Vec<F>,
1069    den: Vec<F>,
1070}
1071
1072/// Is this row served exactly? Positions before the seal are the
1073/// runtime's PROMPT PASS: `Pipeline::nll_ids_o1` runs them through
1074/// ordinary full-KV attention and only then calls `o1_seal`, so the
1075/// skeleton must not touch them.
1076#[inline]
1077fn nys_exact_row(ti: usize, tp: usize) -> bool {
1078    ti < tp
1079}
1080
1081/// near mask (spec §5b): (t−j < W) OR (j < sink), causal.
1082#[inline]
1083fn nys_near(ti: usize, j: usize, w: usize, sink: usize) -> bool {
1084    ti - j < w || j < sink
1085}
1086
1087/// Build the joint weight matrix (docs/RUST_FCD.md §2.2). M comes from
1088/// the RUNTIME's ridge pseudo-inverse and is CONSTANT in backward.
1089/// `mu_override` freezes M explicitly (gradcheck of the constant-M
1090/// convention); None recomputes it from the landmarks.
1091fn nys_graph<F: Fp>(
1092    q: &[F],
1093    k: &[F],
1094    t: usize,
1095    d: usize,
1096    cfg: &NysCfg,
1097    mu_override: Option<&[F]>,
1098) -> NysGraph<F> {
1099    let scale = 1.0 / (d as f64).sqrt();
1100    let fscale = F::fromf(scale);
1101    // Landmarks are sealed from the PROMPT PREFIX, exactly as
1102    // `NystromState::prefill` does — including m_eff, which the runtime
1103    // derives from the prompt length it sealed at, not from how far the
1104    // sequence later runs.
1105    let tp = cfg.prefill_len(t);
1106    let m_eff = (tp / 8).clamp(4, cfg.m);
1107    let mut q_l = vec![F::ZERO; m_eff * d];
1108    let mut k_l = vec![F::ZERO; m_eff * d];
1109    seg_means(&q[..tp * d], tp, d, m_eff, &mut q_l);
1110    seg_means(&k[..tp * d], tp, d, m_eff, &mut k_l);
1111
1112    // Au and its ridge pinv in f64 — one m×m solve, constant in backward.
1113    let mut au = vec![0f64; m_eff * m_eff];
1114    for i in 0..m_eff {
1115        for j in 0..m_eff {
1116            let mut s = 0f64;
1117            for c in 0..d {
1118                s += q_l[i * d + c].f64() * k_l[j * d + c].f64();
1119            }
1120            au[i * m_eff + j] = (s * scale).exp();
1121        }
1122    }
1123    let mu: Vec<F> = match mu_override {
1124        Some(m) => m.to_vec(),
1125        None => crate::nystrom::ridge_pinv(&au, m_eff)
1126            .iter()
1127            .map(|&x| F::fromf(x))
1128            .collect(),
1129    };
1130
1131    // Landmark score factors.
1132    let mut fu = vec![F::ZERO; t * m_eff];
1133    for ti in 0..t {
1134        for i in 0..m_eff {
1135            fu[ti * m_eff + i] =
1136                (dot(&q[ti * d..(ti + 1) * d], &k_l[i * d..(i + 1) * d]) * fscale).exp();
1137        }
1138    }
1139    let mut e = vec![F::ZERO; m_eff * t];
1140    for i in 0..m_eff {
1141        for j in 0..t {
1142            e[i * t + j] = (dot(&q_l[i * d..(i + 1) * d], &k[j * d..(j + 1) * d]) * fscale).exp();
1143        }
1144    }
1145    let mut fumu = vec![F::ZERO; t * m_eff];
1146    matmul_nt(
1147        &fu,
1148        // mu is [m,m] row-major; matmul_nt wants W[m_out, k] rows = muᵀ
1149        // columns… avoid transposition juggling: do it directly.
1150        &transpose(&mu, m_eff, m_eff),
1151        &mut fumu,
1152        t,
1153        m_eff,
1154        m_eff,
1155    );
1156    // a[t,j] = Σ_i fumu[t,i]·e[i,j] — e is [m,t], so column j of e is
1157    // strided; loop with accumulation over i keeps rows contiguous.
1158    // Rows before the seal are exact, so their skeleton is never
1159    // evaluated (and stays ZERO — the backward relies on that).
1160    let mut a = vec![F::ZERO; t * t];
1161    for ti in tp..t {
1162        let fr = &fumu[ti * m_eff..(ti + 1) * m_eff];
1163        let ar = &mut a[ti * t..ti * t + ti + 1]; // causal: j ≤ ti
1164        for (i, &f) in fr.iter().enumerate() {
1165            let er = &e[i * t..i * t + ti + 1];
1166            for (av, ev) in ar.iter_mut().zip(er) {
1167                *av += f * *ev;
1168            }
1169        }
1170    }
1171
1172    // Joint weights with the per-row shift c (constant in backward —
1173    // it multiplies numerator and denominator identically).
1174    let mut wmat = vec![F::ZERO; t * t];
1175    let mut c_row = vec![F::ZERO; t];
1176    let mut den = vec![F::ZERO; t];
1177    let mut far_keep = vec![false; t];
1178    let mut lg_row = vec![F::ZERO; t];
1179    for ti in 0..t {
1180        let qr = &q[ti * d..(ti + 1) * d];
1181        let mut c = F::fromf(f64::NEG_INFINITY);
1182        for j in 0..=ti {
1183            let s = dot(qr, &k[j * d..(j + 1) * d]) * fscale;
1184            lg_row[j] = s;
1185            c = c.maxf(s);
1186        }
1187        c_row[ti] = c;
1188        let emc = (-c).exp();
1189        let exact_row = nys_exact_row(ti, tp);
1190
1191        // AGGREGATE guard (`O1Rect::Aggregate`, the shipped default).
1192        // The runtime never materializes a per-(t,j) weight — it only
1193        // ever holds the far field already contracted into accumulators,
1194        // so the ONLY quantity it can test is the row's total far
1195        // denominator:
1196        //   far_den = Σ_b u[b]·ẑ[b] = e^{−c_all}·Σ_{j far} a[t,j]
1197        // (`nystrom.rs::step`). e^{−c_all} > 0, so the runtime's
1198        // `far_den >= 0` predicate is exactly `Σ_{j far} a[t,j] >= 0`
1199        // here. A row that fails drops its far field ENTIRELY; a row
1200        // that passes keeps every far weight RAW — negative per-key mass
1201        // included. That is not an oversight: clamping per key (what the
1202        // torch probe did, and what this trainer used to do) is a
1203        // strictly different, measurably worse operator (`O1Rect::Fm`,
1204        // ×1.414 vs ×1.296) that no streaming kernel can execute.
1205        let mut far_sum = F::ZERO;
1206        if !exact_row {
1207            for j in 0..=ti {
1208                if !nys_near(ti, j, cfg.w, cfg.sink) {
1209                    far_sum += a[ti * t + j];
1210                }
1211            }
1212        }
1213        let keep = !exact_row && far_sum.f64() >= 0.0;
1214        far_keep[ti] = keep;
1215
1216        let wr = &mut wmat[ti * t..(ti + 1) * t];
1217        let mut dsum = F::ZERO;
1218        for j in 0..=ti {
1219            let wv = if exact_row || nys_near(ti, j, cfg.w, cfg.sink) {
1220                (lg_row[j] - c).exp()
1221            } else if keep {
1222                a[ti * t + j] * emc
1223            } else {
1224                F::ZERO
1225            };
1226            wr[j] = wv;
1227            dsum += wv;
1228        }
1229        den[ti] = dsum.maxf(F::fromf(NYS_DEN_EPS));
1230    }
1231    NysGraph {
1232        m_eff,
1233        tp,
1234        q_l,
1235        k_l,
1236        mu,
1237        fu,
1238        e,
1239        fumu,
1240        far_keep,
1241        wmat,
1242        c_row,
1243        den,
1244    }
1245}
1246
1247fn transpose<F: Fp>(x: &[F], rows: usize, cols: usize) -> Vec<F> {
1248    let mut out = vec![F::ZERO; rows * cols];
1249    for r in 0..rows {
1250        for c in 0..cols {
1251            out[c * rows + r] = x[r * cols + c];
1252        }
1253    }
1254    out
1255}
1256
1257/// Nyström joint forward (teacher-forced matrix form). Falls back to
1258/// exact attention for short windows (runtime guard: t ≤ W+sink+8).
1259#[allow(clippy::too_many_arguments)]
1260pub fn nystrom_head_fwd<F: Fp>(
1261    q: &[F],
1262    k: &[F],
1263    v: &[F],
1264    t: usize,
1265    d: usize,
1266    dv: usize,
1267    cfg: &NysCfg,
1268    out: &mut [F],
1269) {
1270    if nys_degenerate(t, cfg) {
1271        attn_head_fwd(q, k, v, t, d, dv, out);
1272        return;
1273    }
1274    nystrom_head_fwd_mu(q, k, v, t, d, dv, cfg, None, out);
1275}
1276
1277/// Does the runtime skip the skeleton for this window entirely?
1278///
1279/// `NystromState::prefill` decides `exact_only` from the PROMPT length
1280/// (`t <= w + sink + EXACT_SLACK`), not from how long the sequence
1281/// eventually grows: a short prompt seals no skeleton, so every later
1282/// key stays a permanent exact key. Keying this off the full window `t`
1283/// (as this did before) would build a skeleton for windows the runtime
1284/// serves exactly.
1285#[inline]
1286fn nys_degenerate(t: usize, cfg: &NysCfg) -> bool {
1287    cfg.prefill_len(t) <= cfg.w + cfg.sink + 8
1288}
1289
1290/// The landmark mixing matrix M of this (q, k) — gradcheck hook for
1291/// freezing M across finite-difference perturbations.
1292#[doc(hidden)]
1293pub fn nystrom_mu_for_test<F: Fp>(q: &[F], k: &[F], t: usize, d: usize, cfg: &NysCfg) -> Vec<F> {
1294    nys_graph(q, k, t, d, cfg, None).mu
1295}
1296
1297/// Forward with an explicitly frozen M (gradcheck of the constant-M
1298/// convention; the trainer always passes through `nystrom_head_fwd`).
1299#[doc(hidden)]
1300#[allow(clippy::too_many_arguments)]
1301pub fn nystrom_head_fwd_mu<F: Fp>(
1302    q: &[F],
1303    k: &[F],
1304    v: &[F],
1305    t: usize,
1306    d: usize,
1307    dv: usize,
1308    cfg: &NysCfg,
1309    mu_override: Option<&[F]>,
1310    out: &mut [F],
1311) {
1312    let g = nys_graph(q, k, t, d, cfg, mu_override);
1313    for ti in 0..t {
1314        let wr = &g.wmat[ti * t..(ti + 1) * t];
1315        let den = g.den[ti];
1316        let or = &mut out[ti * dv..(ti + 1) * dv];
1317        for o in or.iter_mut() {
1318            *o = F::ZERO;
1319        }
1320        for j in 0..=ti {
1321            let p = wr[j] / den;
1322            if p.f64() != 0.0 {
1323                for (o, vv) in or.iter_mut().zip(&v[j * dv..(j + 1) * dv]) {
1324                    *o += p * *vv;
1325                }
1326            }
1327        }
1328    }
1329}
1330
1331/// Nyström joint backward: dq/dk/dv (+=) with M constant. The whole
1332/// graph is recomputed (layer checkpointing) — see docs/RUST_FCD.md
1333/// §2.2 for the derivation. Chain summary:
1334///   dw[t,j]   = dout_t·(v_j − out_t)/den_t
1335///   near:  dlg = dw·w                     → dq, dk (±scale)
1336///   far:   da  = dw·e^{−c}·[row kept]     → dFu, dE through the two
1337///          matmuls with M constant        → dq, dk, dQ̃, dK̃
1338///   landmarks: segment-mean scatter back into the PREFIX rows of
1339///          dq, dk (the seal only saw those).
1340/// The far gate is per ROW (the aggregate guard), not per key.
1341#[allow(clippy::too_many_arguments)]
1342pub fn nystrom_head_bwd<F: Fp>(
1343    q: &[F],
1344    k: &[F],
1345    v: &[F],
1346    dout: &[F],
1347    t: usize,
1348    d: usize,
1349    dv: usize,
1350    cfg: &NysCfg,
1351    dq: &mut [F],
1352    dk: &mut [F],
1353    dvv: &mut [F],
1354) {
1355    if nys_degenerate(t, cfg) {
1356        attn_head_bwd(q, k, v, dout, t, d, dv, dq, dk, dvv);
1357        return;
1358    }
1359    nystrom_head_bwd_mu(q, k, v, dout, t, d, dv, cfg, None, dq, dk, dvv);
1360}
1361
1362/// Backward with an explicitly frozen M (see `nystrom_head_fwd_mu`).
1363#[doc(hidden)]
1364#[allow(clippy::too_many_arguments)]
1365pub fn nystrom_head_bwd_mu<F: Fp>(
1366    q: &[F],
1367    k: &[F],
1368    v: &[F],
1369    dout: &[F],
1370    t: usize,
1371    d: usize,
1372    dv: usize,
1373    cfg: &NysCfg,
1374    mu_override: Option<&[F]>,
1375    dq: &mut [F],
1376    dk: &mut [F],
1377    dvv: &mut [F],
1378) {
1379    let scale = F::fromf(1.0 / (d as f64).sqrt());
1380    let g = nys_graph(q, k, t, d, cfg, mu_override);
1381    let m_eff = g.m_eff;
1382
1383    // Recompute out rows (needed inside dw), then dv and dw in one pass.
1384    let mut dwmat = vec![F::ZERO; t * t];
1385    let mut out_row = vec![F::ZERO; dv];
1386    for ti in 0..t {
1387        let wr = &g.wmat[ti * t..(ti + 1) * t];
1388        let den = g.den[ti];
1389        for o in out_row.iter_mut() {
1390            *o = F::ZERO;
1391        }
1392        for j in 0..=ti {
1393            let p = wr[j] / den;
1394            if p.f64() != 0.0 {
1395                for (o, vv) in out_row.iter_mut().zip(&v[j * dv..(j + 1) * dv]) {
1396                    *o += p * *vv;
1397                }
1398            }
1399        }
1400        let dor = &dout[ti * dv..(ti + 1) * dv];
1401        let dwr = &mut dwmat[ti * t..(ti + 1) * t];
1402        for j in 0..=ti {
1403            // dV: p·dout
1404            let p = wr[j] / den;
1405            if p.f64() != 0.0 {
1406                for (dvo, o) in dvv[j * dv..(j + 1) * dv].iter_mut().zip(dor) {
1407                    *dvo += p * *o;
1408                }
1409            }
1410            // dw = dout·(v_j − out)/den
1411            let mut s = F::ZERO;
1412            for c in 0..dv {
1413                s += dor[c] * (v[j * dv + c] - out_row[c]);
1414            }
1415            dwr[j] = s / den;
1416        }
1417    }
1418
1419    // Near half: dlg = dw·w, straight to dq/dk. A pre-seal row is
1420    // exact, so EVERY causal j is a near key for it.
1421    for ti in 0..t {
1422        let qr = &q[ti * d..(ti + 1) * d];
1423        let dqr_base = ti * d;
1424        for j in 0..=ti {
1425            if !(nys_exact_row(ti, g.tp) || nys_near(ti, j, cfg.w, cfg.sink)) {
1426                continue;
1427            }
1428            let dlg = dwmat[ti * t + j] * g.wmat[ti * t + j] * scale;
1429            if dlg.f64() == 0.0 {
1430                continue;
1431            }
1432            let kr = &k[j * d..(j + 1) * d];
1433            for c in 0..d {
1434                dq[dqr_base + c] += dlg * kr[c];
1435            }
1436            let dkr = &mut dk[j * d..(j + 1) * d];
1437            for c in 0..d {
1438                dkr[c] += dlg * qr[c];
1439            }
1440        }
1441    }
1442
1443    // Far half: da gated by the AGGREGATE guard, then back through the
1444    // skeleton.
1445    //
1446    // Gradient of the guard: the far field enters as the piecewise
1447    // function  far(row) = [Σ_far a ≥ 0] · a·e^{−c}, i.e. a per-ROW
1448    // switch, not a per-key clamp. On the kept branch it is the identity
1449    // in a — so the far grad flows RAW, with no [a>0] mask (that mask
1450    // belonged to the per-key clamp this replaces and would now zero the
1451    // gradient of exactly the negative-mass keys the shipped operator
1452    // keeps). On the dropped branch the far field is identically 0 in a
1453    // neighbourhood of a, so its gradient is 0. The switching boundary
1454    // itself (Σ_far a = 0) is measure-zero and, like any subgradient
1455    // convention, is not differentiated — at that point far ≡ 0 anyway,
1456    // so the two branches agree in value and only the derivative jumps.
1457    let mut da = vec![F::ZERO; t * t];
1458    for ti in g.tp..t {
1459        if !g.far_keep[ti] {
1460            continue; // dropped row: zero gradient through its far field
1461        }
1462        let emc = (-g.c_row[ti]).exp();
1463        for j in 0..=ti {
1464            if nys_near(ti, j, cfg.w, cfg.sink) {
1465                continue;
1466            }
1467            da[ti * t + j] = dwmat[ti * t + j] * emc;
1468        }
1469    }
1470    // dFuMu[t,i] = Σ_j da[t,j]·e[i,j]
1471    let mut dfumu = vec![F::ZERO; t * m_eff];
1472    for ti in 0..t {
1473        let dar = &da[ti * t..ti * t + ti + 1];
1474        let dfr = &mut dfumu[ti * m_eff..(ti + 1) * m_eff];
1475        for (i, df) in dfr.iter_mut().enumerate() {
1476            let er = &g.e[i * t..i * t + ti + 1];
1477            let mut s = F::ZERO;
1478            for (av, ev) in dar.iter().zip(er) {
1479                s += *av * *ev;
1480            }
1481            *df = s;
1482        }
1483    }
1484    // dFu = dFuMu·Muᵀ  (M constant)
1485    let mut dfu = vec![F::ZERO; t * m_eff];
1486    matmul_nt(&dfumu, &g.mu, &mut dfu, t, m_eff, m_eff);
1487    // dE[i,j] = Σ_t fumu[t,i]·da[t,j]
1488    let mut de = vec![F::ZERO; m_eff * t];
1489    for ti in 0..t {
1490        let dar = &da[ti * t..ti * t + ti + 1];
1491        let fr = &g.fumu[ti * m_eff..(ti + 1) * m_eff];
1492        for (i, &f) in fr.iter().enumerate() {
1493            if f.f64() == 0.0 {
1494                continue;
1495            }
1496            let der = &mut de[i * t..i * t + ti + 1];
1497            for (dev, av) in der.iter_mut().zip(dar) {
1498                *dev += f * *av;
1499            }
1500        }
1501    }
1502    // Chain through the two exponentials into dq/dk and the landmark
1503    // grads dQ̃/dK̃.
1504    let mut dq_l = vec![F::ZERO; m_eff * d];
1505    let mut dk_l = vec![F::ZERO; m_eff * d];
1506    for ti in 0..t {
1507        let qr = &q[ti * d..(ti + 1) * d];
1508        for i in 0..m_eff {
1509            let dlg = dfu[ti * m_eff + i] * g.fu[ti * m_eff + i] * scale;
1510            if dlg.f64() == 0.0 {
1511                continue;
1512            }
1513            let klr = &g.k_l[i * d..(i + 1) * d];
1514            for c in 0..d {
1515                dq[ti * d + c] += dlg * klr[c];
1516            }
1517            let dklr = &mut dk_l[i * d..(i + 1) * d];
1518            for c in 0..d {
1519                dklr[c] += dlg * qr[c];
1520            }
1521        }
1522    }
1523    for i in 0..m_eff {
1524        let qlr = &g.q_l[i * d..(i + 1) * d];
1525        for j in 0..t {
1526            let dlg = de[i * t + j] * g.e[i * t + j] * scale;
1527            if dlg.f64() == 0.0 {
1528                continue;
1529            }
1530            let kr = &k[j * d..(j + 1) * d];
1531            let dqlr = &mut dq_l[i * d..(i + 1) * d];
1532            for c in 0..d {
1533                dqlr[c] += dlg * kr[c];
1534            }
1535            for c in 0..d {
1536                dk[j * d + c] += dlg * qlr[c];
1537            }
1538        }
1539    }
1540    // Landmarks were sealed from the prompt prefix, so their gradient
1541    // scatters back into those rows ONLY — a post-seal token cannot move
1542    // a landmark it never contributed to.
1543    let tp = g.tp;
1544    seg_means_bwd(&dq_l, tp, d, m_eff, &mut dq[..tp * d]);
1545    seg_means_bwd(&dk_l, tp, d, m_eff, &mut dk[..tp * d]);
1546}
1547
1548// ───────────────────────────── losses ─────────────────────────────
1549
1550/// One position of the polish loss (docs/RUST_FCD.md §2.4):
1551/// L = (1−klw)·CE(student, target) + klw·KL(teacher‖student), both
1552/// per-position; `inv_n` = 1/(B·T) folds the batch mean into dlogits.
1553/// Returns (ce, kl) UNWEIGHTED for logging; dlogits gets the combined
1554/// gradient (+= is NOT used — each position owns its slice).
1555pub fn ce_kl_position<F: Fp>(
1556    s_logits: &[F],
1557    t_logits: &[F],
1558    target: usize,
1559    kl_w: f64,
1560    inv_n: f64,
1561    dlogits: &mut [F],
1562) -> (f64, f64) {
1563    let vsz = s_logits.len();
1564    debug_assert_eq!(t_logits.len(), vsz);
1565    // Student log-softmax in f64.
1566    let mut smax = f64::NEG_INFINITY;
1567    let mut tmax = f64::NEG_INFINITY;
1568    for i in 0..vsz {
1569        smax = smax.max(s_logits[i].f64());
1570        tmax = tmax.max(t_logits[i].f64());
1571    }
1572    let mut ssum = 0f64;
1573    let mut tsum = 0f64;
1574    for i in 0..vsz {
1575        ssum += (s_logits[i].f64() - smax).exp();
1576        tsum += (t_logits[i].f64() - tmax).exp();
1577    }
1578    let slz = smax + ssum.ln();
1579    let tlz = tmax + tsum.ln();
1580    let ce = slz - s_logits[target].f64();
1581    let mut kl = 0f64;
1582    for i in 0..vsz {
1583        let ls = s_logits[i].f64() - slz;
1584        let lt = t_logits[i].f64() - tlz;
1585        let pt = lt.exp();
1586        let ps = ls.exp();
1587        if pt > 0.0 {
1588            kl += pt * (lt - ls);
1589        }
1590        let mut gd = (1.0 - kl_w) * ps + kl_w * (ps - pt);
1591        if i == target {
1592            gd -= 1.0 - kl_w;
1593        }
1594        dlogits[i] = F::fromf(gd * inv_n);
1595    }
1596    (ce, kl)
1597}
1598
1599// ─────────────────── GatedDeltaNet through-backward ───────────────────
1600//
1601// Faithful BPTT through `linear_core::gdn_step` semantics (docs/
1602// RUST_FCD.md §3): depthwise causal conv + SiLU, per-group l2-normalized
1603// q/k, gates g = exp(−exp(A_log)·softplus(a+dt_bias)) and β = σ(b), the
1604// delta-rule recurrence S ← g·S; kv = Sᵀk̂; S += k̂⊗β(v−kv); o = Sᵀq̂, and
1605// the gated per-head RMSNorm output x̂·w·silu(z). Through-grad ONLY —
1606// every GDN weight stays frozen (the FCD policy: attention operators
1607// are closed-form/frozen, training touches LN+FFN of converted layers).
1608//
1609// The backward stores the full per-head state history S_0..S_T (one
1610// head at a time: T·dk·dv floats — ~67 MB f64 at Qwen3.5-0.8B geometry,
1611// freed per head). Larger models would switch to segment checkpoints;
1612// the entry points below don't change for that.
1613
1614/// GDN geometry + frozen elementwise weights for the sequence ops.
1615pub struct GdnSeqCfg<'a> {
1616    pub nv: usize,
1617    pub nk: usize,
1618    pub dk: usize,
1619    pub dv: usize,
1620    pub kk: usize,
1621    pub rms_eps: f64,
1622    /// Depthwise conv taps `[c_dim × kk]`, oldest→newest (tap kk−1
1623    /// multiplies the current position) — `GdnWeights::conv1d` layout.
1624    pub conv: &'a [f32],
1625    /// Per-v-head decay parameter A_log `[nv]`.
1626    pub a_log: &'a [f32],
1627    /// Per-v-head dt bias `[nv]`.
1628    pub dt_bias: &'a [f32],
1629    /// Gated-RMSNorm gain `[dv]` (plain x̂·w, norm-before-gate).
1630    pub norm: &'a [f32],
1631}
1632
1633impl GdnSeqCfg<'_> {
1634    pub fn c_dim(&self) -> usize {
1635        2 * self.nk * self.dk + self.nv * self.dv
1636    }
1637}
1638
1639#[inline]
1640fn softplus_f<F: Fp>(x: F) -> F {
1641    // Same threshold as linear_core::softplus — parity matters more
1642    // than elegance (σ(20) ≈ 1 − 2e-9, consistent with the cutoff).
1643    if x.f64() > 20.0 {
1644        x
1645    } else {
1646        F::fromf(x.f64().exp().ln_1p())
1647    }
1648}
1649
1650#[inline]
1651fn sigmoid_f<F: Fp>(x: F) -> F {
1652    F::ONE / (F::ONE + (-x).exp())
1653}
1654
1655/// Depthwise causal conv + SiLU over the whole window: raw `[t, c_dim]`
1656/// → (pre-activation `[t, c_dim]`, cq = silu(pre)). Ring semantics of
1657/// `gdn_step` from a fresh state: positions before 0 are zeros.
1658pub fn gdn_conv_fwd<F: Fp>(
1659    raw: &[F],
1660    t: usize,
1661    c_dim: usize,
1662    kk: usize,
1663    conv: &[f32],
1664    pre: &mut [F],
1665    cq: &mut [F],
1666) {
1667    for ti in 0..t {
1668        for c in 0..c_dim {
1669            let taps = &conv[c * kk..(c + 1) * kk];
1670            let mut acc = F::ZERO;
1671            for (j, &tap) in taps.iter().enumerate() {
1672                // tap j multiplies raw position ti − (kk−1) + j.
1673                let p = ti as isize - (kk as isize - 1) + j as isize;
1674                if p >= 0 {
1675                    acc += raw[p as usize * c_dim + c] * F::fromf(tap as f64);
1676                }
1677            }
1678            pre[ti * c_dim + c] = acc;
1679            cq[ti * c_dim + c] = silu(acc);
1680        }
1681    }
1682}
1683
1684/// Conv+SiLU backward: dcq → draw (+=). Taps frozen (through-grad only).
1685pub fn gdn_conv_bwd<F: Fp>(
1686    pre: &[F],
1687    t: usize,
1688    c_dim: usize,
1689    kk: usize,
1690    conv: &[f32],
1691    dcq: &[F],
1692    draw: &mut [F],
1693) {
1694    for ti in 0..t {
1695        for c in 0..c_dim {
1696            let g = dcq[ti * c_dim + c];
1697            if g.f64() == 0.0 {
1698                continue;
1699            }
1700            let dp = g * silu_bwd(pre[ti * c_dim + c]);
1701            let taps = &conv[c * kk..(c + 1) * kk];
1702            for (j, &tap) in taps.iter().enumerate() {
1703                let p = ti as isize - (kk as isize - 1) + j as isize;
1704                if p >= 0 {
1705                    draw[p as usize * c_dim + c] += dp * F::fromf(tap as f64);
1706                }
1707            }
1708        }
1709    }
1710}
1711
1712/// l2-normalization factors of one q/k vector, matching gdn_step:
1713/// invq = 1/(√(Σq²+1e-6)·√dk), invk = 1/√(Σk²+1e-6).
1714#[inline]
1715fn gdn_inv<F: Fp>(x: &[F], extra_scale: f64) -> (F, F) {
1716    let mut n2 = F::ZERO;
1717    for v in x {
1718        n2 += *v * *v;
1719    }
1720    let n2e = n2 + F::fromf(1e-6);
1721    let inv = F::ONE / (n2e.sqrt() * F::fromf(extra_scale));
1722    (inv, n2e)
1723}
1724
1725/// One GQA group (k-head `ko`, its rep = nv/nk v-heads) forward over
1726/// the window. Writes `out[t, nv·dv]` slices of its v-heads only.
1727#[allow(clippy::too_many_arguments)]
1728pub fn gdn_group_fwd<F: Fp>(
1729    cq: &[F],
1730    z: &[F],
1731    a: &[F],
1732    b: &[F],
1733    t: usize,
1734    cfg: &GdnSeqCfg,
1735    ko: usize,
1736    out: &mut [F],
1737) {
1738    let (nv, nk, dk, dv) = (cfg.nv, cfg.nk, cfg.dk, cfg.dv);
1739    let c_dim = cfg.c_dim();
1740    let kd = nk * dk;
1741    let rep = nv / nk;
1742    let vd = nv * dv;
1743    let sqdk = (dk as f64).sqrt();
1744    for hh in 0..rep {
1745        let h = ko * rep + hh;
1746        let ea = F::fromf((cfg.a_log[h] as f64).exp());
1747        let mut s = vec![F::ZERO; dk * dv];
1748        let mut kv = vec![F::ZERO; dv];
1749        let mut o = vec![F::ZERO; dv];
1750        for ti in 0..t {
1751            let qrow = &cq[ti * c_dim + ko * dk..ti * c_dim + (ko + 1) * dk];
1752            let krow = &cq[ti * c_dim + kd + ko * dk..ti * c_dim + kd + (ko + 1) * dk];
1753            let vrow = &cq[ti * c_dim + 2 * kd + h * dv..ti * c_dim + 2 * kd + (h + 1) * dv];
1754            let (invq, _) = gdn_inv(qrow, sqdk);
1755            let (invk, _) = gdn_inv(krow, 1.0);
1756            let g = (-ea * softplus_f(a[ti * nv + h] + F::fromf(cfg.dt_bias[h] as f64))).exp();
1757            let beta = sigmoid_f(b[ti * nv + h]);
1758            // S ← g·S; kv = Sᵀk̂; S += k̂ ⊗ β(v − kv); o = Sᵀq̂.
1759            for x in kv.iter_mut() {
1760                *x = F::ZERO;
1761            }
1762            for di in 0..dk {
1763                let kf = krow[di] * invk;
1764                let row = &mut s[di * dv..(di + 1) * dv];
1765                for dj in 0..dv {
1766                    row[dj] *= g;
1767                    kv[dj] += row[dj] * kf;
1768                }
1769            }
1770            for x in o.iter_mut() {
1771                *x = F::ZERO;
1772            }
1773            for di in 0..dk {
1774                let kf = krow[di] * invk;
1775                let qf = qrow[di] * invq;
1776                let row = &mut s[di * dv..(di + 1) * dv];
1777                for dj in 0..dv {
1778                    row[dj] += kf * (vrow[dj] - kv[dj]) * beta;
1779                    o[dj] += qf * row[dj];
1780                }
1781            }
1782            // Gated per-head RMSNorm: x̂·w·silu(z), norm BEFORE gate.
1783            let mut ss = 0f64;
1784            for v in &o {
1785                ss += v.f64() * v.f64();
1786            }
1787            let inv = F::fromf(1.0 / (ss / dv as f64 + cfg.rms_eps).sqrt());
1788            for dj in 0..dv {
1789                let zv = z[ti * vd + h * dv + dj];
1790                out[ti * vd + h * dv + dj] = o[dj] * inv * F::fromf(cfg.norm[dj] as f64) * silu(zv);
1791            }
1792        }
1793    }
1794}
1795
1796/// One GQA group backward: BPTT over the window given `dout` rows of
1797/// this group's v-heads. Accumulates (+=) into dcq (q/k channels of
1798/// `ko`, v channels of its v-heads), dz, da, db. All weights frozen.
1799#[allow(clippy::too_many_arguments)]
1800pub fn gdn_group_bwd<F: Fp>(
1801    cq: &[F],
1802    z: &[F],
1803    a: &[F],
1804    b: &[F],
1805    t: usize,
1806    cfg: &GdnSeqCfg,
1807    ko: usize,
1808    dout: &[F],
1809    dcq: &mut [F],
1810    dz: &mut [F],
1811    da: &mut [F],
1812    db: &mut [F],
1813) {
1814    let (nv, nk, dk, dv) = (cfg.nv, cfg.nk, cfg.dk, cfg.dv);
1815    let c_dim = cfg.c_dim();
1816    let kd = nk * dk;
1817    let rep = nv / nk;
1818    let vd = nv * dv;
1819    let sqdk = (dk as f64).sqrt();
1820    for hh in 0..rep {
1821        let h = ko * rep + hh;
1822        let ea = F::fromf((cfg.a_log[h] as f64).exp());
1823
1824        // ── replay forward, keeping the state history + per-step scalars ──
1825        let mut s_hist = vec![F::ZERO; (t + 1) * dk * dv]; // S_0 = 0
1826        let mut kv_hist = vec![F::ZERO; t * dv];
1827        let mut o_hist = vec![F::ZERO; t * dv];
1828        let mut g_v = vec![F::ZERO; t];
1829        let mut beta_v = vec![F::ZERO; t];
1830        let mut sp_arg = vec![F::ZERO; t]; // a + dt_bias (for σ in the chain)
1831        for ti in 0..t {
1832            let qrow = &cq[ti * c_dim + ko * dk..ti * c_dim + (ko + 1) * dk];
1833            let krow = &cq[ti * c_dim + kd + ko * dk..ti * c_dim + kd + (ko + 1) * dk];
1834            let vrow = &cq[ti * c_dim + 2 * kd + h * dv..ti * c_dim + 2 * kd + (h + 1) * dv];
1835            let (invq, _) = gdn_inv(qrow, sqdk);
1836            let (invk, _) = gdn_inv(krow, 1.0);
1837            let arg = a[ti * nv + h] + F::fromf(cfg.dt_bias[h] as f64);
1838            let g = (-ea * softplus_f(arg)).exp();
1839            let beta = sigmoid_f(b[ti * nv + h]);
1840            sp_arg[ti] = arg;
1841            g_v[ti] = g;
1842            beta_v[ti] = beta;
1843            let (prev, cur) = s_hist.split_at_mut((ti + 1) * dk * dv);
1844            let sp = &prev[ti * dk * dv..];
1845            let sn = &mut cur[..dk * dv];
1846            let kvr = &mut kv_hist[ti * dv..(ti + 1) * dv];
1847            for di in 0..dk {
1848                let kf = krow[di] * invk;
1849                for dj in 0..dv {
1850                    let dec = sp[di * dv + dj] * g;
1851                    sn[di * dv + dj] = dec;
1852                    kvr[dj] += dec * kf;
1853                }
1854            }
1855            let or = &mut o_hist[ti * dv..(ti + 1) * dv];
1856            for di in 0..dk {
1857                let kf = krow[di] * invk;
1858                let qf = qrow[di] * invq;
1859                for dj in 0..dv {
1860                    let sv = sn[di * dv + dj] + kf * (vrow[dj] - kvr[dj]) * beta_v[ti];
1861                    sn[di * dv + dj] = sv;
1862                    or[dj] += qf * sv;
1863                }
1864            }
1865        }
1866
1867        // ── reverse sweep ──
1868        let mut ds = vec![F::ZERO; dk * dv];
1869        let mut do_o = vec![F::ZERO; dv];
1870        let mut du = vec![F::ZERO; dv];
1871        let mut dkv = vec![F::ZERO; dv];
1872        let mut dqh = vec![F::ZERO; dk];
1873        let mut dkh = vec![F::ZERO; dk];
1874        for ti in (0..t).rev() {
1875            let qrow = &cq[ti * c_dim + ko * dk..ti * c_dim + (ko + 1) * dk];
1876            let krow = &cq[ti * c_dim + kd + ko * dk..ti * c_dim + kd + (ko + 1) * dk];
1877            let vrow = &cq[ti * c_dim + 2 * kd + h * dv..ti * c_dim + 2 * kd + (h + 1) * dv];
1878            let (invq, nq2) = gdn_inv(qrow, sqdk);
1879            let (invk, nk2) = gdn_inv(krow, 1.0);
1880            let g = g_v[ti];
1881            let beta = beta_v[ti];
1882            let s_t = &s_hist[(ti + 1) * dk * dv..(ti + 2) * dk * dv];
1883            let s_prev = &s_hist[ti * dk * dv..(ti + 1) * dk * dv];
1884            let kvr = &kv_hist[ti * dv..(ti + 1) * dv];
1885            let or = &o_hist[ti * dv..(ti + 1) * dv];
1886
1887            // 1. Gated RMSNorm output: of = (o·inv)·w·silu(z).
1888            let mut ss = 0f64;
1889            for v in or {
1890                ss += v.f64() * v.f64();
1891            }
1892            let inv = F::fromf(1.0 / (ss / dv as f64 + cfg.rms_eps).sqrt());
1893            let dofr = &dout[ti * vd + h * dv..ti * vd + (h + 1) * dv];
1894            // dz and the effective-gain through-grad in one pass.
1895            let mut sdot = 0f64; // Σ dof·weff·o (for the rms through term)
1896            for dj in 0..dv {
1897                let zv = z[ti * vd + h * dv + dj];
1898                let w = F::fromf(cfg.norm[dj] as f64);
1899                let weff = w * silu(zv);
1900                sdot += (dofr[dj] * weff * or[dj]).f64();
1901                dz[ti * vd + h * dv + dj] += dofr[dj] * or[dj] * inv * w * silu_bwd(zv);
1902            }
1903            let coef = F::fromf(sdot / dv as f64) * inv * inv * inv;
1904            for dj in 0..dv {
1905                let zv = z[ti * vd + h * dv + dj];
1906                let weff = F::fromf(cfg.norm[dj] as f64) * silu(zv);
1907                do_o[dj] = inv * weff * dofr[dj] - or[dj] * coef;
1908            }
1909
1910            // 2. o = S_tᵀ q̂ → dS += q̂ ⊗ do, dq̂ = S_t·do.
1911            for x in dqh.iter_mut() {
1912                *x = F::ZERO;
1913            }
1914            for di in 0..dk {
1915                let qf = qrow[di] * invq;
1916                let row = &s_t[di * dv..(di + 1) * dv];
1917                let dsr = &mut ds[di * dv..(di + 1) * dv];
1918                let mut acc = F::ZERO;
1919                for dj in 0..dv {
1920                    dsr[dj] += qf * do_o[dj];
1921                    acc += row[dj] * do_o[dj];
1922                }
1923                dqh[di] = acc;
1924            }
1925
1926            // 3. S_t = S_pre + k̂ ⊗ u, u = β(v − kv):
1927            //    du = dSᵀk̂; dk̂ = dS·u; dβ = du·(v−kv); dv = β·du; dkv = −β·du.
1928            for x in du.iter_mut() {
1929                *x = F::ZERO;
1930            }
1931            for x in dkh.iter_mut() {
1932                *x = F::ZERO;
1933            }
1934            for di in 0..dk {
1935                let kf = krow[di] * invk;
1936                let dsr = &ds[di * dv..(di + 1) * dv];
1937                let mut acc = F::ZERO;
1938                for dj in 0..dv {
1939                    du[dj] += dsr[dj] * kf;
1940                    acc += dsr[dj] * (vrow[dj] - kvr[dj]) * beta;
1941                }
1942                dkh[di] = acc;
1943            }
1944            let mut dbeta = F::ZERO;
1945            for dj in 0..dv {
1946                dbeta += du[dj] * (vrow[dj] - kvr[dj]);
1947                // v channels of cq
1948                dcq[ti * c_dim + 2 * kd + h * dv + dj] += beta * du[dj];
1949                dkv[dj] = -(beta * du[dj]);
1950            }
1951
1952            // 4. kv = S_preᵀk̂ → dS_pre = dS + k̂ ⊗ dkv; dk̂ += S_pre·dkv.
1953            //    5. S_pre = g·S_{t−1} → dg = ⟨dS_pre, S_{t−1}⟩; carry
1954            //    dS = g·dS_pre to the previous step. (S_pre = g·s_prev
1955            //    is rebuilt on the fly.)
1956            let mut dg = F::ZERO;
1957            for di in 0..dk {
1958                let kf = krow[di] * invk;
1959                let spr = &s_prev[di * dv..(di + 1) * dv];
1960                let dsr = &mut ds[di * dv..(di + 1) * dv];
1961                let mut acc = F::ZERO;
1962                for dj in 0..dv {
1963                    let dspre = dsr[dj] + kf * dkv[dj];
1964                    acc += (spr[dj] * g) * dkv[dj];
1965                    dg += dspre * spr[dj];
1966                    dsr[dj] = g * dspre;
1967                }
1968                dkh[di] += acc;
1969            }
1970
1971            // 6. Gates: g = exp(−e_A·softplus(arg)), β = σ(b).
1972            let sig = sigmoid_f(sp_arg[ti]);
1973            da[ti * nv + h] += dg * g * (-ea) * sig;
1974            db[ti * nv + h] += dbeta * beta * (F::ONE - beta);
1975
1976            // 7. l2-norm through-grads into the shared q/k channels.
1977            //    q̂ = q·invq with invq = 1/(√(Σq²+eps)·√dk):
1978            //    dq = invq·dq̂ − q·(dq̂·q)·invq/(Σq²+eps).
1979            let mut qdot = F::ZERO;
1980            let mut kdot = F::ZERO;
1981            for di in 0..dk {
1982                qdot += dqh[di] * qrow[di];
1983                kdot += dkh[di] * krow[di];
1984            }
1985            for di in 0..dk {
1986                dcq[ti * c_dim + ko * dk + di] += invq * dqh[di] - qrow[di] * qdot * invq / nq2;
1987                dcq[ti * c_dim + kd + ko * dk + di] +=
1988                    invk * dkh[di] - krow[di] * kdot * invk / nk2;
1989            }
1990        }
1991    }
1992}
1993
1994/// Whole-layer GDN sequence forward (serial over groups): raw
1995/// projections → out `[t, nv·dv]`. The trainer parallelizes groups on
1996/// the pool with the same `gdn_group_*` entry points.
1997pub fn gdn_seq_fwd<F: Fp>(
1998    qkv: &[F],
1999    z: &[F],
2000    a: &[F],
2001    b: &[F],
2002    t: usize,
2003    cfg: &GdnSeqCfg,
2004    out: &mut [F],
2005) {
2006    let c_dim = cfg.c_dim();
2007    let mut pre = vec![F::ZERO; t * c_dim];
2008    let mut cq = vec![F::ZERO; t * c_dim];
2009    gdn_conv_fwd(qkv, t, c_dim, cfg.kk, cfg.conv, &mut pre, &mut cq);
2010    for ko in 0..cfg.nk {
2011        gdn_group_fwd(&cq, z, a, b, t, cfg, ko, out);
2012    }
2013}
2014
2015/// Whole-layer GDN sequence backward: through-grads (+=) into the four
2016/// projection streams.
2017#[allow(clippy::too_many_arguments)]
2018pub fn gdn_seq_bwd<F: Fp>(
2019    qkv: &[F],
2020    z: &[F],
2021    a: &[F],
2022    b: &[F],
2023    t: usize,
2024    cfg: &GdnSeqCfg,
2025    dout: &[F],
2026    dqkv: &mut [F],
2027    dz: &mut [F],
2028    da: &mut [F],
2029    db: &mut [F],
2030) {
2031    let c_dim = cfg.c_dim();
2032    let mut pre = vec![F::ZERO; t * c_dim];
2033    let mut cq = vec![F::ZERO; t * c_dim];
2034    gdn_conv_fwd(qkv, t, c_dim, cfg.kk, cfg.conv, &mut pre, &mut cq);
2035    let mut dcq = vec![F::ZERO; t * c_dim];
2036    for ko in 0..cfg.nk {
2037        gdn_group_bwd(&cq, z, a, b, t, cfg, ko, dout, &mut dcq, dz, da, db);
2038    }
2039    gdn_conv_bwd(&pre, t, c_dim, cfg.kk, cfg.conv, &dcq, dqkv);
2040}
2041
2042#[cfg(test)]
2043mod gpu_bake_tests {
2044    /// The bake's GPU backward must answer what the CPU answers: same
2045    /// contraction, different order of summation, so a relative 1e-4 is
2046    /// the honest bar (f32 reductions differ by shape).
2047    #[test]
2048    #[cfg(feature = "gpu")]
2049    fn gemm_dx_gpu_matches_cpu() {
2050        if std::env::var("CMF_GPU").is_err() {
2051            eprintln!("no backend requested — skip");
2052            return;
2053        }
2054        let (n, k, m) = (8usize, 512usize, 1024usize);
2055        let dy: Vec<f32> = (0..n * m)
2056            .map(|i| ((i * 37 % 101) as f32 - 50.0) / 50.0)
2057            .collect();
2058        let w: Vec<f32> = (0..m * k)
2059            .map(|i| ((i * 17 % 97) as f32 - 48.0) / 48.0)
2060            .collect();
2061        let mut want = vec![0f32; n * k];
2062        super::gemm_dx(&dy, &w, &mut want, n, k, m, None);
2063        let mut got = vec![0f32; n * k];
2064        if !crate::gpu_wgpu::gemm_dx_f32(&dy, &w, &mut got, n, k, m) {
2065            eprintln!("device declined — skip");
2066            return;
2067        }
2068        let num: f64 = want
2069            .iter()
2070            .zip(&got)
2071            .map(|(a, b)| ((a - b) as f64).powi(2))
2072            .sum();
2073        let den: f64 = want
2074            .iter()
2075            .map(|a| (*a as f64).powi(2))
2076            .sum::<f64>()
2077            .max(1e-30);
2078        let rel = (num / den).sqrt();
2079        assert!(rel < 1e-4, "gemm_dx GPU vs CPU rel {rel:e}");
2080    }
2081}