Skip to main content

rlx_ir/ops/
spd_eig.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Backend-agnostic **symmetric-eigendecomposition spectral layers** built from
17//! primitive ops + `Op::Scan` — the GPU-capable lowering for the opaque,
18//! CPU-only (f64 LAPACK) `Op::ReEig` / `Op::LogEig` SPD-manifold kernels.
19//!
20//! SPDNet-family models (`spdnet`, `tensorcspnet`, `graphcspnet`, `tsmnet`) map
21//! covariance descriptors through the SPD manifold with ReEig
22//! (`Y = U·max(ε,Σ)·Uᵀ`, the SPD ReLU) and LogEig (`Y = U·log(Σ)·Uᵀ`, the tangent
23//! projection). The native kernels compute the eigendecomposition with LAPACK in
24//! f64 and only exist on CPU. This module expresses the same math as a graph of
25//! primitives (`mm`/`add`/`sub`/`mul`/`div`/`sqrt`/`neg`/`narrow`/`concat`/
26//! `transpose`/`Activation::Log` + `Op::Scan`) so it lowers to **every backend**.
27//!
28//! **Eigensolver = graph-based cyclic Jacobi via `Op::Scan`.** A fully-unrolled
29//! Jacobi explodes the graph (`sweeps · n²/2` rotations → GPU shader compile is
30//! super-linear in node count). Instead `Op::Scan` compiles the one-sweep body
31//! **once** and iterates it → the graph stays compact and compiles+runs at
32//! `n = 20+`. Each `p<q` rotation applies `J = I + (c−1)(E_pp+E_qq) + s·E_pq −
33//! s·E_qp` (basis-const matrices scaled by `[1,1]` scalars) as `A ← Jᵀ·A·J`,
34//! `V ← V·J`.
35//!
36//! **Precision.** Plain f32 is sufficient — an f32 Jacobi eigensolver matches the
37//! f64 LAPACK reference to cos = 1.0 even at condition 1e7 for the small SPD
38//! matrices these models use, so no f64 / double-single is needed. A **signed
39//! denominator floor** (`|den| ≥ 1e-6`, sign preserved) is required in f32 or an
40//! off-diagonal `a_pq ≈ 0` overflows `tau` → `NaN`. Constants are emitted in the
41//! input node's dtype, so an f64 graph (CPU) stays f64 and an f32 graph (GPU)
42//! stays f32.
43
44use crate::infer::GraphExt;
45use crate::op::{Activation, Op};
46use crate::{DType, Graph, NodeId, Shape};
47
48/// Default cyclic-Jacobi sweep count. Every GPU backend host-falls-back
49/// `Op::Scan` (and the opt-in unroll bakes one round-block per sweep), so unlike
50/// a native on-device scan each extra sweep is real host compute / graph nodes —
51/// margin is NOT free. Measured convergence to cos = 1.0: spdnet/tsmnet/
52/// tensorcspnet at ≤6, graphcspnet (spd_dim 36, worst case) at 5 (cos 0.999987 at
53/// 4). Cyclic Jacobi converges QUADRATICALLY, so sweeps past the convergence
54/// point are near-no-ops on already-diagonal matrices — measured cost is ~linear
55/// in sweeps (tensorcspnet/coreml ~8 s/sweep), and cos is already 1.0 at 6 for
56/// every SPD crate (0.999995 at 4). 6 = full convergence + 1 sweep of headroom,
57/// ~2.5× faster than the old 15 (and ~25% faster than 8). Under-convergence
58/// surfaces as a failing parity test, never silent drift, so this is safe to
59/// trim. Override with `RLX_SPD_JACOBI_SWEEPS`.
60pub const SPD_JACOBI_SWEEPS: u32 = 6;
61
62/// Effective sweep count, honoring the `RLX_SPD_JACOBI_SWEEPS` env override.
63pub fn spd_jacobi_sweeps() -> u32 {
64    std::env::var("RLX_SPD_JACOBI_SWEEPS")
65        .ok()
66        .and_then(|s| s.parse::<u32>().ok())
67        .filter(|&v| v > 0)
68        .unwrap_or(SPD_JACOBI_SWEEPS)
69}
70
71/// Emit `xs` as little-endian bytes in `dtype` (f64 verbatim, everything else as f32).
72fn const_bytes(xs: &[f64], dtype: DType) -> Vec<u8> {
73    match dtype {
74        DType::F64 => xs.iter().flat_map(|v| v.to_le_bytes()).collect(),
75        _ => xs.iter().flat_map(|v| (*v as f32).to_le_bytes()).collect(),
76    }
77}
78
79/// A constant matrix `[dims]` in `dtype`.
80fn cmat(g: &mut Graph, xs: &[f64], dims: &[usize], dtype: DType) -> NodeId {
81    g.add_node(
82        Op::Constant {
83            data: const_bytes(xs, dtype),
84        },
85        vec![],
86        Shape::new(dims, dtype),
87    )
88}
89
90/// A `[1,1]` constant scalar in `dtype`.
91fn cscalar(g: &mut Graph, v: f64, dtype: DType) -> NodeId {
92    cmat(g, &[v], &[1, 1], dtype)
93}
94
95/// Round-robin (circle-method) 1-factorization of `K_ne` (`ne` even): `ne-1`
96/// rounds, each a set of `ne/2` **disjoint** `(p,q)` pairs covering all vertices.
97/// Over the rounds every `p<q` pair appears exactly once — one full cyclic-Jacobi
98/// sweep, but split so each round's rotations act on disjoint index pairs and can
99/// be applied **simultaneously** (they commute).
100fn round_robin_rounds(ne: usize) -> Vec<Vec<(usize, usize)>> {
101    let mut players: Vec<usize> = (0..ne).collect();
102    let mut rounds = Vec::with_capacity(ne - 1);
103    for _ in 0..ne - 1 {
104        let pairs: Vec<(usize, usize)> = (0..ne / 2)
105            .map(|i| {
106                let (a, b) = (players[i], players[ne - 1 - i]);
107                (a.min(b), a.max(b))
108            })
109            .collect();
110        rounds.push(pairs);
111        // Circle method: keep players[0] fixed, rotate players[1..] by one.
112        let last = players[ne - 1];
113        for i in (2..ne).rev() {
114            players[i] = players[i - 1];
115        }
116        players[1] = last;
117    }
118    rounds
119}
120
121/// Diagonal of a `[k,k]` matrix as a `[k,1]` column via `sum(m ⊙ I_k, axis1)`
122/// (last-axis reduce → lowers everywhere).
123fn diag_k(g: &mut Graph, m: NodeId, ident_k: NodeId) -> NodeId {
124    let d = g.mul(m, ident_k);
125    g.sum(d, vec![1], true) // [k,1]
126}
127
128/// **One parallel round** of `k = ne/2` disjoint Jacobi rotations, encoded by the
129/// per-round selection matrices `spp/sqq` (`[n,k]`, column `i` = one-hot of pair
130/// `i`'s `p`/`q` row). All `k` rotations are computed from the same `av` and
131/// applied as ONE combined orthogonal `J` — the constant-size `Op::Scan` body.
132/// Extraction: `a_pp = diag(sppᵀ·av·spp)` etc. (vectorised over the `k` pairs);
133/// `J = I + spp·diag(c−1)·sppᵀ + sqq·diag(c−1)·sqqᵀ + spp·diag(s)·sqqᵀ −
134/// sqq·diag(s)·sppᵀ`. Numerically robust in f32 via the signed floor + `sbias`.
135#[allow(clippy::too_many_arguments)]
136fn one_round(
137    g: &mut Graph,
138    av: NodeId,
139    vv: NodeId,
140    spp: NodeId,
141    sqq: NodeId,
142    ident_n: NodeId,
143    ident_k: NodeId,
144    dt: DType,
145) -> (NodeId, NodeId) {
146    let one = cscalar(g, 1.0, dt);
147    let two = cscalar(g, 2.0, dt);
148    let tiny = cscalar(g, 1e-30, dt);
149    let sbias = cscalar(g, 1e-12, dt);
150    let floor = cscalar(g, 1e-6, dt);
151
152    let sppt = g.transpose_(spp, vec![1, 0]); // [k,n]
153    let sqqt = g.transpose_(sqq, vec![1, 0]);
154    let tpp = g.mm(sppt, av); // [k,n]
155    let m_pp = g.mm(tpp, spp); // [k,k]
156    let app = diag_k(g, m_pp, ident_k); // [k,1]
157    let tqq = g.mm(sqqt, av);
158    let m_qq = g.mm(tqq, sqq);
159    let aqq = diag_k(g, m_qq, ident_k);
160    let m_pq = g.mm(tpp, sqq); // sppᵀ·av·sqq
161    let apq = diag_k(g, m_pq, ident_k);
162
163    // Per-pair c,s (vectorised over the [k,1] columns; scalars broadcast).
164    let num = g.sub(aqq, app);
165    let den0 = g.mul(two, apq);
166    let den0b = g.add(den0, sbias); // sbias breaks exact-zero → sign +1 (no NaN)
167    let den0sq = g.mul(den0b, den0b);
168    let den0abs = g.sqrt(den0sq);
169    let den0abst = g.add(den0abs, tiny);
170    let sgnden = g.div(den0b, den0abst);
171    let sfloor = g.mul(sgnden, floor);
172    let den = g.add(den0, sfloor);
173    let tau = g.div(num, den);
174    let tau2 = g.mul(tau, tau);
175    let atau = g.sqrt(tau2);
176    let satau = g.add(atau, tiny);
177    let sgn = g.div(tau, satau);
178    let tau2p1 = g.add(tau2, one);
179    let rt = g.sqrt(tau2p1);
180    let tden = g.add(atau, rt);
181    let t = g.div(sgn, tden);
182    let t2 = g.mul(t, t);
183    let t2p1 = g.add(t2, one);
184    let rc = g.sqrt(t2p1);
185    let c = g.div(one, rc);
186    let s = g.mul(t, c);
187    let cm1 = g.sub(c, one);
188    let negs = g.neg(s);
189
190    // Diagonal [k,k] matrices from the [k,1] vectors, then scatter to [n,n] via
191    // the selection matmuls.
192    let d_cm1 = g.mul(cm1, ident_k); // [k,k] diag(c−1)
193    let d_s = g.mul(s, ident_k);
194    let d_negs = g.mul(negs, ident_k);
195    let pp_diag = {
196        let l = g.mm(spp, d_cm1);
197        g.mm(l, sppt)
198    };
199    let qq_diag = {
200        let l = g.mm(sqq, d_cm1);
201        g.mm(l, sqqt)
202    };
203    let pq = {
204        let l = g.mm(spp, d_s);
205        g.mm(l, sqqt)
206    };
207    let qp = {
208        let l = g.mm(sqq, d_negs);
209        g.mm(l, sppt)
210    };
211    let j1 = g.add(ident_n, pp_diag);
212    let j2 = g.add(j1, qq_diag);
213    let j3 = g.add(j2, pq);
214    let jj = g.add(j3, qp);
215    let jt = g.transpose_(jj, vec![1, 0]);
216    let jta = g.mm(jt, av);
217    let av2 = g.mm(jta, jj);
218    let vv2 = g.mm(vv, jj);
219    (av2, vv2)
220}
221
222/// Constant-size parallel-round scan body: inputs `[carry, spp, sqq]` (carry
223/// first, then the 2 per-round `xs`).
224fn round_body(n: usize, k: usize, dt: DType) -> Graph {
225    let mut body = Graph::new("jacobi_round");
226    let carry = body.input("carry", Shape::new(&[2 * n, n], dt));
227    let spp = body.input("spp", Shape::new(&[n, k], dt));
228    let sqq = body.input("sqq", Shape::new(&[n, k], dt));
229    let a_in = body.narrow_(carry, 0, 0, n);
230    let v_in = body.narrow_(carry, 0, n, n);
231    let mut ident = vec![0f64; n * n];
232    for i in 0..n {
233        ident[i * n + i] = 1.0;
234    }
235    let ident_n = cmat(&mut body, &ident, &[n, n], dt);
236    let mut idk = vec![0f64; k * k];
237    for i in 0..k {
238        idk[i * k + i] = 1.0;
239    }
240    let ident_k = cmat(&mut body, &idk, &[k, k], dt);
241    let (a_out, v_out) = one_round(&mut body, a_in, v_in, spp, sqq, ident_n, ident_k, dt);
242    let out = body.concat_(vec![a_out, v_out], 0);
243    body.set_outputs(vec![out]);
244    body
245}
246
247/// Run the **parallel** Jacobi eigensolver on symmetric `a` (`[n,n]`) and return
248/// `(av, vv)`: `av`'s diagonal holds the eigenvalues, `vv`'s columns the
249/// eigenvectors.
250///
251/// Each sweep is `ne-1` **rounds** (round-robin 1-factorization, `ne = n` rounded
252/// up to even), and each round applies `ne/2` disjoint rotations at once. So one
253/// sweep is `ne-1` sequential `Op::Scan` steps instead of `n(n-1)/2` — an ~`n/2`×
254/// reduction in the dependent-dispatch count that dominates GPU runtime for large
255/// `n`. The body is constant-size (compiles once); per-round selection matrices
256/// `spp/sqq` (`[ne-1,n,k]`, one sweep's worth, tiny) drive it. Odd `n` pads to
257/// `ne = n+1` with a phantom index whose zero selector columns make it a no-op.
258fn eigensolve(g: &mut Graph, a: NodeId, n: usize, sweeps: u32, dt: DType) -> (NodeId, NodeId) {
259    let mut ident = vec![0f64; n * n];
260    for i in 0..n {
261        ident[i * n + i] = 1.0;
262    }
263    let iv = cmat(g, &ident, &[n, n], dt);
264    if n < 2 {
265        return (a, iv); // 1×1: eigenvalue = a, eigenvector = 1.
266    }
267    let mut carry = g.concat_(vec![a, iv], 0); // [2n, n] = A over V(=I)
268
269    let ne = if n.is_multiple_of(2) { n } else { n + 1 };
270    let k = ne / 2;
271    let rounds = round_robin_rounds(ne);
272    let nr = rounds.len(); // ne - 1
273
274    // Native UNROLLED path (RLX_SPD_UNROLL=1): apply each round's `one_round`
275    // DIRECTLY on the parent graph instead of through `Op::Scan`. Op::Scan has no
276    // native GPU kernel — it host-falls-back to the CPU executor, so every one of
277    // the `nr·sweeps` steps round-trips GPU→CPU→GPU. That sync dominates runtime
278    // for large batch / large `n` (tensorcspnet, graphcspnet n=36). Unrolling keeps
279    // the whole eigensolve on-device (bigger graph, but the matmul kernel is reused
280    // and there are no per-round host round-trips). Numerically identical to the
281    // scan path — it calls the SAME `one_round`. Opt-in so the default small-graph
282    // scan is unchanged for very large models.
283    //
284    // DEFAULT = single-scan (below): it works on ALL backends including CoreML
285    // (one host segment instead of `sweeps` separately-compiled MIL segments —
286    // tensorcspnet/graphcspnet go from "never finishes on CoreML" to ~3 min), and
287    // its wall-time is competitive with the native unroll on GPU (tensorcspnet:
288    // wgpu 40 s single-scan vs 59 s unroll). The native UNROLL (no `Op::Scan`,
289    // fully on-device) stays available for GPU-only workloads via
290    // `RLX_SPD_UNROLL=1`, but it is NOT the default because its ~n²·sweeps node
291    // count blows up CoreML's MIL compile (~125k nodes).
292    if std::env::var("RLX_SPD_UNROLL").as_deref() == Ok("1") {
293        let mut idk = vec![0f64; k * k];
294        for i in 0..k {
295            idk[i * k + i] = 1.0;
296        }
297        let ident_k = cmat(g, &idk, &[k, k], dt);
298        let sel: Vec<(NodeId, NodeId)> = rounds
299            .iter()
300            .map(|round| {
301                let mut sp = vec![0f64; n * k];
302                let mut sq = vec![0f64; n * k];
303                for (i, &(p, q)) in round.iter().enumerate() {
304                    if p < n {
305                        sp[p * k + i] = 1.0;
306                    }
307                    if q < n {
308                        sq[q * k + i] = 1.0;
309                    }
310                }
311                (cmat(g, &sp, &[n, k], dt), cmat(g, &sq, &[n, k], dt))
312            })
313            .collect();
314        // `iv` (the [n,n] identity built above) doubles as the read-only `ident_n`
315        // constant and the eigenvector accumulator's initial value.
316        let (mut av, mut vv) = (a, iv);
317        for _ in 0..sweeps {
318            for &(spp_r, sqq_r) in &sel {
319                let (a2, v2) = one_round(g, av, vv, spp_r, sqq_r, iv, ident_k, dt);
320                av = a2;
321                vv = v2;
322            }
323        }
324        return (av, vv);
325    }
326
327    // Per-round selection stacks TILED over all sweeps into [nr·sweeps, n, k], so
328    // the whole eigensolve is a SINGLE `Op::Scan` (length nr·sweeps) rather than
329    // `sweeps` separate scans. This matters most on CoreML: every `Op::Scan`
330    // host-splits the graph into its own separately-compiled+loaded MIL segment,
331    // so `sweeps` scans → ~sweeps+1 model compiles (minutes for tensorcspnet/
332    // graphcspnet); one scan → ~2 segments. It also collapses the per-sweep
333    // GPU↔host transfers of the host-fallback scan to a single round-trip on
334    // every GPU backend. Numerically identical — same rounds in the same order.
335    // Column i one-hots pair i's p/q row (phantom index == n → zero selector →
336    // no-op rotation).
337    let total = nr * sweeps as usize;
338    let mut spp = vec![0f64; total * n * k];
339    let mut sqq = vec![0f64; total * n * k];
340    for s in 0..sweeps as usize {
341        for (r, round) in rounds.iter().enumerate() {
342            let base = (s * nr + r) * n * k;
343            for (i, &(p, q)) in round.iter().enumerate() {
344                if p < n {
345                    spp[base + p * k + i] = 1.0;
346                }
347                if q < n {
348                    sqq[base + q * k + i] = 1.0;
349                }
350            }
351        }
352    }
353    let spp_c = cmat(g, &spp, &[total, n, k], dt);
354    let sqq_c = cmat(g, &sqq, &[total, n, k], dt);
355    let body = round_body(n, k, dt);
356    carry = g.scan_with_xs(carry, &[spp_c, sqq_c], body, total as u32);
357    let av = g.narrow_(carry, 0, 0, n);
358    let vv = g.narrow_(carry, 0, n, n);
359    (av, vv)
360}
361
362/// Spectral matrix function applied to the (floored) eigenvalues — the tail
363/// after `max(λ, eps)`. Selects which SPD matrix function a spectral layer
364/// computes: `Re` = ReEig (`max`), `Log` = LogEig (`log∘max`), `Sqrt` = matrix
365/// square root (`√∘max`, `G^{1/2}`), `InvSqrt` = inverse square root
366/// (`1/√∘max`, `M^{-1/2}`).
367#[derive(Clone, Copy, PartialEq, Eq)]
368pub enum SpectralFn {
369    Re,
370    Log,
371    Sqrt,
372    InvSqrt,
373}
374
375/// Diagonal `diag(f(λ_i))` matrix `[n,n]` for the selected [`SpectralFn`].
376/// Also returns the per-eigenvalue `[1]` raw-λ parts (for packing the
377/// backward-reuse buffer).
378fn spectral_fmat(
379    g: &mut Graph,
380    av: NodeId,
381    n: usize,
382    eps: f64,
383    f: SpectralFn,
384    dt: DType,
385) -> (NodeId, Vec<NodeId>) {
386    let epsn = cscalar(g, eps, dt);
387    let half = cscalar(g, 0.5, dt);
388    let one = cscalar(g, 1.0, dt);
389    let mut terms: Vec<NodeId> = Vec::with_capacity(n);
390    let mut lam_parts: Vec<NodeId> = Vec::with_capacity(n);
391    for i in 0..n {
392        let ri = g.narrow_(av, 0, i, 1); // [1,n]
393        let lam = g.narrow_(ri, 1, i, 1); // [1,1]
394        lam_parts.push(g.reshape_(lam, vec![1])); // raw eigenvalue λ_i
395        // max(λ, eps) = 0.5·((λ+eps) + |λ−eps|)
396        let sumv = g.add(lam, epsn);
397        let diff = g.sub(lam, epsn);
398        let d2 = g.mul(diff, diff);
399        let ad = g.sqrt(d2);
400        let sad = g.add(sumv, ad);
401        let mx = g.mul(half, sad);
402        let fl = match f {
403            SpectralFn::Re => mx,
404            SpectralFn::Log => g.activation(Activation::Log, mx, Shape::new(&[1, 1], dt)),
405            SpectralFn::Sqrt => g.sqrt(mx),
406            SpectralFn::InvSqrt => {
407                let s = g.sqrt(mx);
408                g.div(one, s)
409            }
410        };
411        let mut eii = vec![0f64; n * n];
412        eii[i * n + i] = 1.0;
413        let eii = cmat(g, &eii, &[n, n], dt);
414        terms.push(g.mul(fl, eii));
415    }
416    let mut fmat = terms[0];
417    for t in &terms[1..] {
418        fmat = g.add(fmat, *t);
419    }
420    (fmat, lam_parts)
421}
422
423/// `V · diag(f(max(λ, eps))) · Vᵀ` of symmetric `a` (`[n,n]`) for an arbitrary
424/// spectral matrix function `f` — the general SPD matrix-function builder used to
425/// lower ReEig / LogEig / matrix-sqrt (`G^{1/2}`) / inverse-sqrt (`M^{-1/2}`).
426pub fn spectral_matfn(
427    g: &mut Graph,
428    a: NodeId,
429    n: usize,
430    sweeps: u32,
431    eps: f64,
432    f: SpectralFn,
433) -> NodeId {
434    let dt = g.shape(a).dtype();
435    let (av, vv) = eigensolve(g, a, n, sweeps, dt);
436    let (fmat, _) = spectral_fmat(g, av, n, eps, f, dt);
437    let vt = g.transpose_(vv, vec![1, 0]);
438    let vf = g.mm(vv, fmat);
439    g.mm(vf, vt)
440}
441
442// ── Batched eigensolver over `[B,n,n]` ──────────────────────────────────────
443//
444// The SPD models eigendecompose MANY independent matrices (per batch × channel).
445// Running them as separate `Op::Scan`s is slow — every GPU backend host-falls-
446// back `Op::Scan` (runs on the CPU host executor), so B separate scans = B×
447// single-matrix host loops. Batching them into ONE scan over `[B,n,n]` cuts the
448// host-scan iteration count by ~B and turns the per-round work into BLAS-batched
449// matmuls. `rlx` `mm` broadcasts leading (batch) dims, so the same parallel
450// round runs over the whole batch; per-round selection matrices `[n,k]` are
451// shared (broadcast) across the batch. The batched spectral tail is also simpler
452// (no per-eigenvalue loop): `diag(av)` = `sum(av ⊙ I, last-axis)` → `[B,n,1]`.
453
454fn ident_mat3(g: &mut Graph, n: usize, dt: DType) -> NodeId {
455    let mut m = vec![0f64; n * n];
456    for i in 0..n {
457        m[i * n + i] = 1.0;
458    }
459    cmat(g, &m, &[1, n, n], dt)
460}
461
462/// Shared Jacobi `(c−1, s, −s)` from `(app, aqq, apq)` — broadcast-shape agnostic
463/// (`[1,1]` single or `[B,k,1]` batched). Signed floor + `sbias` exact-zero fix.
464fn jacobi_cs(
465    g: &mut Graph,
466    app: NodeId,
467    aqq: NodeId,
468    apq: NodeId,
469    dt: DType,
470) -> (NodeId, NodeId, NodeId) {
471    let one = cscalar(g, 1.0, dt);
472    let two = cscalar(g, 2.0, dt);
473    let tiny = cscalar(g, 1e-30, dt);
474    let sbias = cscalar(g, 1e-12, dt);
475    let floor = cscalar(g, 1e-6, dt);
476    let num = g.sub(aqq, app);
477    let den0 = g.mul(two, apq);
478    let den0b = g.add(den0, sbias);
479    let den0sq = g.mul(den0b, den0b);
480    let den0abs = g.sqrt(den0sq);
481    let den0abst = g.add(den0abs, tiny);
482    let sgnden = g.div(den0b, den0abst);
483    let sfloor = g.mul(sgnden, floor);
484    let den = g.add(den0, sfloor);
485    let tau = g.div(num, den);
486    let tau2 = g.mul(tau, tau);
487    let atau = g.sqrt(tau2);
488    let satau = g.add(atau, tiny);
489    let sgn = g.div(tau, satau);
490    let tau2p1 = g.add(tau2, one);
491    let rt = g.sqrt(tau2p1);
492    let tden = g.add(atau, rt);
493    let t = g.div(sgn, tden);
494    let t2 = g.mul(t, t);
495    let t2p1 = g.add(t2, one);
496    let rc = g.sqrt(t2p1);
497    let c = g.div(one, rc);
498    let s = g.mul(t, c);
499    let cm1 = g.sub(c, one);
500    let negs = g.neg(s);
501    (cm1, s, negs)
502}
503
504/// One parallel round over a BATCH `av/vv: [B,n,n]`. `spp/sqq: [n,k]` are shared
505/// (broadcast) across the batch; `ident_n:[n,n]`, `ident_k:[k,k]`.
506#[allow(clippy::too_many_arguments)]
507fn one_round_b(
508    g: &mut Graph,
509    av: NodeId,
510    vv: NodeId,
511    spp: NodeId,
512    sqq: NodeId,
513    ident_n: NodeId,
514    ident_k: NodeId,
515    dt: DType,
516) -> (NodeId, NodeId) {
517    // spp/sqq/ident_n/ident_k are rank-3 `[1,·,·]` (batch-broadcast against `av`).
518    let sppt = g.transpose_(spp, vec![0, 2, 1]); // [1,k,n]
519    let sqqt = g.transpose_(sqq, vec![0, 2, 1]);
520    let tpp = g.mm(sppt, av); // [B,k,n]
521    let m_pp = g.mm(tpp, spp); // [B,k,k]
522    let app = {
523        let d = g.mul(m_pp, ident_k);
524        g.sum(d, vec![2], true)
525    }; // [B,k,1]
526    let tqq = g.mm(sqqt, av);
527    let m_qq = g.mm(tqq, sqq);
528    let aqq = {
529        let d = g.mul(m_qq, ident_k);
530        g.sum(d, vec![2], true)
531    };
532    let m_pq = g.mm(tpp, sqq);
533    let apq = {
534        let d = g.mul(m_pq, ident_k);
535        g.sum(d, vec![2], true)
536    };
537    let (cm1, s, negs) = jacobi_cs(g, app, aqq, apq, dt);
538    let d_cm1 = g.mul(cm1, ident_k); // [B,k,k] diag
539    let d_s = g.mul(s, ident_k);
540    let d_negs = g.mul(negs, ident_k);
541    let pp_diag = {
542        let l = g.mm(spp, d_cm1);
543        g.mm(l, sppt)
544    };
545    let qq_diag = {
546        let l = g.mm(sqq, d_cm1);
547        g.mm(l, sqqt)
548    };
549    let pq = {
550        let l = g.mm(spp, d_s);
551        g.mm(l, sqqt)
552    };
553    let qp = {
554        let l = g.mm(sqq, d_negs);
555        g.mm(l, sppt)
556    };
557    let j1 = g.add(ident_n, pp_diag);
558    let j2 = g.add(j1, qq_diag);
559    let j3 = g.add(j2, pq);
560    let jj = g.add(j3, qp);
561    let jt = g.transpose_(jj, vec![0, 2, 1]); // batched transpose
562    let jta = g.mm(jt, av);
563    let av2 = g.mm(jta, jj);
564    let vv2 = g.mm(vv, jj);
565    (av2, vv2)
566}
567
568/// Constant-size batched round scan body: inputs `[carry [B,2n,n], spp, sqq]`.
569fn round_body_b(batch: usize, n: usize, k: usize, dt: DType) -> Graph {
570    let mut body = Graph::new("jacobi_round_b");
571    let carry = body.input("carry", Shape::new(&[batch, 2 * n, n], dt));
572    let spp = body.input("spp", Shape::new(&[n, k], dt));
573    let sqq = body.input("sqq", Shape::new(&[n, k], dt));
574    let a_in = body.narrow_(carry, 1, 0, n); // [B,n,n]
575    let v_in = body.narrow_(carry, 1, n, n);
576    // Per-step selectors + identities are rank-3 `[1,·,·]`, broadcast across the
577    // batch by the (now broadcast-aware) batched matmul.
578    let _ = batch;
579    let spp3 = body.reshape_(spp, vec![1, n as i64, k as i64]);
580    let sqq3 = body.reshape_(sqq, vec![1, n as i64, k as i64]);
581    let ident_n = ident_mat3(&mut body, n, dt);
582    let ident_k = ident_mat3(&mut body, k, dt);
583    let (a_out, v_out) = one_round_b(&mut body, a_in, v_in, spp3, sqq3, ident_n, ident_k, dt);
584    let out = body.concat_(vec![a_out, v_out], 1); // [B,2n,n]
585    body.set_outputs(vec![out]);
586    body
587}
588
589/// Batched parallel-Jacobi eigensolver: `a: [B,n,n]` → `(av, vv): [B,n,n]`.
590fn eigensolve_b(
591    g: &mut Graph,
592    a: NodeId,
593    batch: usize,
594    n: usize,
595    sweeps: u32,
596    dt: DType,
597) -> (NodeId, NodeId) {
598    // iv = identity per batch, baked as a full [B,n,n] constant (avoid a 1-vs-B
599    // broadcast — rlx batched matmul/broadcast mishandles a batch-1 operand).
600    let mut ivdata = vec![0f64; batch * n * n];
601    for b in 0..batch {
602        for i in 0..n {
603            ivdata[b * n * n + i * n + i] = 1.0;
604        }
605    }
606    let iv = cmat(g, &ivdata, &[batch, n, n], dt);
607    if n < 2 {
608        return (a, iv);
609    }
610    let mut carry = g.concat_(vec![a, iv], 1); // [B, 2n, n]
611
612    let ne = if n.is_multiple_of(2) { n } else { n + 1 };
613    let k = ne / 2;
614    let rounds = round_robin_rounds(ne);
615    let nr = rounds.len();
616    let mut spp = vec![0f64; nr * n * k];
617    let mut sqq = vec![0f64; nr * n * k];
618    for (r, round) in rounds.iter().enumerate() {
619        for (i, &(p, q)) in round.iter().enumerate() {
620            if p < n {
621                spp[r * n * k + p * k + i] = 1.0;
622            }
623            if q < n {
624                sqq[r * n * k + q * k + i] = 1.0;
625            }
626        }
627    }
628    let spp_c = cmat(g, &spp, &[nr, n, k], dt);
629    let sqq_c = cmat(g, &sqq, &[nr, n, k], dt);
630    let xs = [spp_c, sqq_c];
631    for _ in 0..sweeps {
632        let body = round_body_b(batch, n, k, dt);
633        carry = g.scan_with_xs(carry, &xs, body, nr as u32);
634    }
635    let av = g.narrow_(carry, 1, 0, n);
636    let vv = g.narrow_(carry, 1, n, n);
637    (av, vv)
638}
639
640/// `V·diag(f(max(λ,eps)))·Vᵀ` over a BATCH `a: [B,n,n]` — the batched analogue of
641/// [`spectral_matfn`]. One scan for the whole batch → far fewer host-scan loops.
642pub fn spectral_matfn_batched(
643    g: &mut Graph,
644    a: NodeId,
645    batch: usize,
646    n: usize,
647    sweeps: u32,
648    eps: f64,
649    f: SpectralFn,
650) -> NodeId {
651    let dt = g.shape(a).dtype();
652    let ident_n = ident_mat3(g, n, dt); // [1,n,n], broadcast in the elementwise muls
653    let (av, vv) = eigensolve_b(g, a, batch, n, sweeps, dt);
654    // diag(av) → [B,n,1]; apply f(max(·,eps)); rebuild diag [B,n,n].
655    let epsn = cscalar(g, eps, dt);
656    let half = cscalar(g, 0.5, dt);
657    let one = cscalar(g, 1.0, dt);
658    let diag_av = {
659        let d = g.mul(av, ident_n);
660        g.sum(d, vec![2], true)
661    }; // [B,n,1]
662    let sumv = g.add(diag_av, epsn);
663    let diff = g.sub(diag_av, epsn);
664    let d2 = g.mul(diff, diff);
665    let ad = g.sqrt(d2);
666    let sad = g.add(sumv, ad);
667    let mx = g.mul(half, sad); // max(λ,eps) [B,n,1]
668    let fl = match f {
669        SpectralFn::Re => mx,
670        SpectralFn::Log => g.activation(Activation::Log, mx, Shape::new(&[batch, n, 1], dt)),
671        SpectralFn::Sqrt => g.sqrt(mx),
672        SpectralFn::InvSqrt => {
673            let s = g.sqrt(mx);
674            g.div(one, s)
675        }
676    };
677    let fmat = g.mul(fl, ident_n); // [B,n,n] diag(f)
678    let vt = g.transpose_(vv, vec![0, 2, 1]);
679    let vf = g.mm(vv, fmat);
680    g.mm(vf, vt)
681}
682
683impl Graph {
684    /// Batched graph-primitive **ReEig** over `x: [B,n,n]` — GPU-capable, one scan
685    /// for the whole batch. See [`spectral_matfn_batched`].
686    pub fn spd_reeig_batched(
687        &mut self,
688        x: NodeId,
689        batch: usize,
690        n: usize,
691        sweeps: u32,
692        eps: f64,
693    ) -> NodeId {
694        spectral_matfn_batched(self, x, batch, n, sweeps, eps, SpectralFn::Re)
695    }
696
697    /// Batched graph-primitive **LogEig** over `x: [B,n,n]`.
698    pub fn spd_logeig_batched(
699        &mut self,
700        x: NodeId,
701        batch: usize,
702        n: usize,
703        sweeps: u32,
704        eps: f64,
705    ) -> NodeId {
706        spectral_matfn_batched(self, x, batch, n, sweeps, eps, SpectralFn::Log)
707    }
708}
709
710/// BiMap SPDNet layer `Y = W · X · Wᵀ` as two matmuls — the GPU-portable
711/// replacement for `Op::BiMap`. `w` is `[m,n]`, `x` is `[n,n]`; output `[m,m]`.
712pub fn bimap(g: &mut Graph, w: NodeId, x: NodeId) -> NodeId {
713    let wt = g.transpose_(w, vec![1, 0]);
714    let wx = g.mm(w, x);
715    g.mm(wx, wt)
716}
717
718/// SPD batch-norm transport `Y_i = G^{1/2} (M^{-1/2} X_i M^{-1/2}) G^{1/2}` as
719/// graph primitives — the GPU-portable replacement for `Op::SpdBatchNorm`.
720/// `x` is `[batch,n,n]`, `mean` and `g_` are `[n,n]`; output matches `x`.
721pub fn spd_batch_norm_transport(
722    g: &mut Graph,
723    x: NodeId,
724    mean: NodeId,
725    g_: NodeId,
726    n: usize,
727    batch: usize,
728    sweeps: u32,
729    eps: f64,
730) -> NodeId {
731    let ms = spectral_matfn(g, mean, n, sweeps, eps, SpectralFn::InvSqrt); // M^{-1/2}
732    let gs = spectral_matfn(g, g_, n, sweeps, eps, SpectralFn::Sqrt); // G^{1/2}
733    let mut slices = Vec::with_capacity(batch);
734    for bi in 0..batch {
735        let xi = g.narrow_(x, 0, bi, 1);
736        let xi = g.reshape_(xi, vec![n as i64, n as i64]);
737        let mx = g.mm(ms, xi); // Ms Xi
738        let ci = g.mm(mx, ms); // Ms Xi Ms
739        let gc = g.mm(gs, ci); // Gs Ci
740        let yi = g.mm(gc, gs); // Gs Ci Gs
741        slices.push(g.reshape_(yi, vec![1, n as i64, n as i64]));
742    }
743    g.concat_(slices, 0)
744}
745
746/// `V · diag(max(λ, eps)) · Vᵀ` — the **ReEig** eigenvalue-rectification SPD
747/// layer (`[n,n]` → `[n,n]`). GPU-portable replacement for `Op::ReEig`.
748pub fn spectral_reeig(g: &mut Graph, a: NodeId, n: usize, sweeps: u32, eps: f64) -> NodeId {
749    spectral_matfn(g, a, n, sweeps, eps, SpectralFn::Re)
750}
751
752/// `V · diag(log max(λ, eps)) · Vᵀ` — the **LogEig** matrix-log / tangent-space
753/// SPD layer (`[n,n]` → `[n,n]`). GPU-portable replacement for `Op::LogEig`.
754pub fn spectral_logeig(g: &mut Graph, a: NodeId, n: usize, sweeps: u32, eps: f64) -> NodeId {
755    spectral_matfn(g, a, n, sweeps, eps, SpectralFn::Log)
756}
757
758/// Packed `[2n²+n]` buffer `Y ∥ λ ∥ U` matching the native `Op::ReEig` /
759/// `Op::LogEig` output layout (so the downstream `Narrow(0,0,n²)` that the
760/// manifold builder emits selects `Y` unchanged). `log` picks LogEig vs ReEig.
761/// This is what the [`Op::ReEig`]/[`Op::LogEig`] → primitive lowering emits.
762pub fn spectral_packed(
763    g: &mut Graph,
764    a: NodeId,
765    n: usize,
766    sweeps: u32,
767    eps: f64,
768    log: bool,
769) -> NodeId {
770    let dt = g.shape(a).dtype();
771    let (av, vv) = eigensolve(g, a, n, sweeps, dt);
772    let f = if log { SpectralFn::Log } else { SpectralFn::Re };
773    let (fmat, lam_parts) = spectral_fmat(g, av, n, eps, f, dt);
774    let vt = g.transpose_(vv, vec![1, 0]);
775    let vf = g.mm(vv, fmat);
776    let y = g.mm(vf, vt); // [n,n]
777    let y_flat = g.reshape_(y, vec![(n * n) as i64]); // Y  [n²]
778    let lam = g.concat_(lam_parts, 0); // λ  [n]
779    let u_flat = g.reshape_(vv, vec![(n * n) as i64]); // U  [n²]
780    g.concat_(vec![y_flat, lam, u_flat], 0) // [2n²+n]
781}
782
783impl Graph {
784    /// Graph-primitive **ReEig** (`V·diag(max(λ,eps))·Vᵀ`) via the Jacobi
785    /// eigensolver — a GPU-capable drop-in for [`Graph::reeig`]. `x` is `[n,n]`.
786    pub fn spectral_reeig(&mut self, x: NodeId, sweeps: u32, eps: f64) -> NodeId {
787        let n = self.shape(x).dim(0).unwrap_static();
788        spectral_reeig(self, x, n, sweeps, eps)
789    }
790
791    /// Graph-primitive **LogEig** (`V·diag(log max(λ,eps))·Vᵀ`) via the Jacobi
792    /// eigensolver — a GPU-capable drop-in for [`Graph::logeig`]. `x` is `[n,n]`.
793    pub fn spectral_logeig(&mut self, x: NodeId, sweeps: u32, eps: f64) -> NodeId {
794        let n = self.shape(x).dim(0).unwrap_static();
795        spectral_logeig(self, x, n, sweeps, eps)
796    }
797}