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