Skip to main content

oxmera_tensor/
linalg.rs

1//! Small batched linear algebra on the last two dimensions: identity and
2//! diagonal constructors, trace, Cholesky, log-determinant and the
3//! symmetric eigen-decomposition.
4//!
5//! `eye`, `diag`, `diag_embed`, `trace`, `logdet` and `det` are composites
6//! of recorded ops and therefore run and differentiate on every backend.
7//! `cholesky` and `eigh` are backend primitives; a backend that declines
8//! them falls back to the CPU implementation through a device round-trip,
9//! the same contract `index_select` uses. Sizes are meant to be small
10//! (covariance blocks, determinantal kernels), where the round-trip is
11//! cheap and exactness matters more than throughput.
12
13use oxmera_core::{DType, Device, Error, Result, Shape};
14
15use crate::autograd::GradFn;
16use crate::autograd::is_recording;
17use crate::backend::{Backend, backend_for};
18use crate::tensor::Tensor;
19
20fn square_matrix_dims(t: &Tensor, op: &'static str) -> Result<(usize, usize)> {
21    let d = t.dims();
22    if d.len() < 2 || d[d.len() - 1] != d[d.len() - 2] {
23        return Err(Error::InvalidArgument {
24            op,
25            detail: format!("needs a [.., n, n] tensor, got shape {:?}", t.shape()),
26        });
27    }
28    let n = d[d.len() - 1];
29    let batch: usize = d[..d.len() - 2].iter().product();
30    Ok((batch, n))
31}
32
33/// Run a linear-algebra primitive on the tensor's backend, falling back
34/// to a CPU round-trip when the backend declines.
35fn dispatch_cpu_fallback<T>(
36    t: &Tensor,
37    f: impl Fn(&dyn Backend, &Tensor) -> Result<T>,
38    upload: impl Fn(&dyn Backend, T) -> Result<T>,
39) -> Result<T> {
40    let backend = backend_for(t.device())?;
41    match f(backend.as_ref(), t) {
42        Err(Error::NotImplemented { .. }) if t.device() != Device::Cpu => {
43            let cpu = backend.download(t)?;
44            let out = f(backend_for(Device::Cpu)?.as_ref(), &cpu)?;
45            upload(backend.as_ref(), out)
46        }
47        other => other,
48    }
49}
50
51impl Tensor {
52    /// The `n × n` identity matrix on the CPU.
53    /// # Panics
54    /// Panics if `n * n` overflows `usize`. Unchecked, this wrapped to a
55    /// short allocation and surfaced as an index-out-of-bounds below.
56    pub fn eye(n: usize) -> Tensor {
57        let cells = n
58            .checked_mul(n)
59            .expect("eye: n * n overflows usize — the identity is too large to build");
60        let mut v = vec![0.0f32; cells];
61        for i in 0..n {
62            v[i * n + i] = 1.0;
63        }
64        Tensor::from_vec_f32(v, Shape::from([n, n])).expect("lengths match by construction")
65    }
66
67    /// The `n × n` identity matrix on `device`.
68    pub fn eye_on(n: usize, device: Device) -> Result<Tensor> {
69        Tensor::eye(n).to_device(device)
70    }
71
72    /// The identity with the dtype and device of `like` (VJP plumbing).
73    fn eye_like(n: usize, like: &Tensor) -> Result<Tensor> {
74        Tensor::eye(n)
75            .to_dtype(like.dtype())?
76            .to_device(like.device())
77    }
78
79    /// The diagonal of every matrix in a `[.., n, n]` tensor, as `[.., n]`.
80    /// Differentiable.
81    pub fn diag(&self) -> Result<Tensor> {
82        let (_, n) = square_matrix_dims(self, "diag")?;
83        let eye = Tensor::eye_like(n, self)?;
84        self.mul(&eye)?.sum(&[self.ndim() - 1])
85    }
86
87    /// Matrices with the vectors of a `[.., n]` tensor on their diagonals,
88    /// as `[.., n, n]`. Differentiable.
89    pub fn diag_embed(&self) -> Result<Tensor> {
90        let d = self.dims();
91        let Some(&n) = d.last() else {
92            return Err(Error::InvalidArgument {
93                op: "diag_embed",
94                detail: "needs rank >= 1".into(),
95            });
96        };
97        let eye = Tensor::eye_like(n, self)?;
98        self.unsqueeze(self.ndim())?.mul(&eye)
99    }
100
101    /// The trace of every matrix in a `[.., n, n]` tensor, as `[..]`.
102    /// Differentiable.
103    pub fn trace(&self) -> Result<Tensor> {
104        // Report `trace`: the caller never wrote `diag`, and an error naming
105        // it sends them looking for a call that does not exist.
106        let diag = self.diag().map_err(|e| match e {
107            Error::InvalidArgument { detail, .. } => Error::InvalidArgument {
108                op: "trace",
109                detail,
110            },
111            other => other,
112        })?;
113        diag.sum(&[diag.ndim() - 1])
114    }
115
116    /// Lower-triangular Cholesky factor `L` of every symmetric
117    /// positive-definite matrix in a `[.., n, n]` tensor, `L Lᵀ = A`.
118    ///
119    /// Reads the lower triangle. A matrix that is not positive definite
120    /// is a typed [`Error::InvalidArgument`] naming the batch index and
121    /// pivot. The backward pass runs on the host and returns to the input's
122    /// device; its intermediates are `f64`, but it takes its inputs as
123    /// `f32`, so an `f64` gradient carries `f32` precision.
124    ///
125    /// # Gradient convention
126    ///
127    /// The gradient (Murray 2016) is taken with respect to **symmetric
128    /// perturbations** of the input: `d logdet/dA = A⁻¹`, which is the
129    /// standard result and what PyTorch returns. Because the forward reads
130    /// only the lower triangle, an *elementwise* finite difference — which
131    /// perturbs one entry and so breaks symmetry — does not agree with it,
132    /// and `oxmera_autograd::gradcheck` cannot be used on `cholesky`,
133    /// `logdet` or `det` directly. Check them through a symmetrizer
134    /// (`(X + Xᵀ)/2`), as `tests/gradcheck.rs` does, or against `A⁻¹`.
135    ///
136    /// The practical consequence: feed these ops a symmetric matrix. Given
137    /// an asymmetric one the forward silently uses the lower triangle while
138    /// the gradient describes a symmetric matrix, and the two disagree.
139    pub fn cholesky(&self) -> Result<Tensor> {
140        square_matrix_dims(self, "cholesky")?;
141        let out = dispatch_cpu_fallback(self, |be, t| be.cholesky(t), |be, l| be.upload(&l))?;
142        if !(is_recording() && self.is_tracked()) {
143            return Ok(out);
144        }
145        let l = out.clone();
146        let shape = self.shape().clone();
147        let device = self.device();
148        Ok(out.with_grad_fn(GradFn {
149            inputs: vec![self.clone()],
150            vjp: Box::new(move |g: &Tensor| {
151                let (batch, n) = square_matrix_dims(&l, "cholesky backward")?;
152                let dtype = l.dtype();
153                let lv = l
154                    .to_device(Device::Cpu)?
155                    .to_dtype(DType::F32)?
156                    .to_vec_f32()?;
157                let gv = g
158                    .to_device(Device::Cpu)?
159                    .to_dtype(DType::F32)?
160                    .to_vec_f32()?;
161                let grad = crate::cpu_linalg::cholesky_backward(&lv, &gv, batch, n);
162                let grad = Tensor::from_vec_f32(grad, shape.clone())?
163                    .to_dtype(dtype)?
164                    .to_device(device)?;
165                Ok(vec![Some(grad)])
166            }),
167        }))
168    }
169
170    /// `ln det A` of every SPD matrix in a `[.., n, n]` tensor, as `[..]`,
171    /// through the Cholesky factor: `2 Σ ln diag(L)`. Differentiable
172    /// (the gradient is `A⁻¹`, symmetrized).
173    pub fn logdet(&self) -> Result<Tensor> {
174        let l = self.cholesky()?;
175        let d = l.diag()?;
176        d.ln()?.sum(&[d.ndim() - 1])?.mul_scalar(2.0)
177    }
178
179    /// `det A` of every SPD matrix in a `[.., n, n]` tensor, as `[..]`,
180    /// through the Cholesky factor. Differentiable. For an indefinite
181    /// matrix use `eigh` — this is the SPD determinant.
182    pub fn det(&self) -> Result<Tensor> {
183        self.logdet()?.exp()
184    }
185
186    /// Eigen-decomposition of every symmetric matrix in a `[.., n, n]`
187    /// tensor: eigenvalues ascending as `[.., n]` and orthonormal
188    /// eigenvectors as the columns of `[.., n, n]` (`A V = V Λ`). Not
189    /// differentiable.
190    ///
191    /// The input must be symmetric to within [`EIGH_SYMMETRY_TOL`]
192    /// (relative); anything further is a typed [`Error::InvalidArgument`]
193    /// naming the batch index and the worst offending pair.
194    ///
195    /// Before 0.4.0 the full matrix was read and silently symmetrized, so
196    /// a non-symmetric input returned the eigenpairs of `(A + Aᵀ)/2` — a
197    /// different matrix — with no error: `[[1, 2], [5, 1]]` answered
198    /// `[-2.5, 4.5]` where the true eigenvalues are `1 ± √10`, and the
199    /// returned pair did not satisfy `A v = λ v` for the `A` that was
200    /// passed. The tolerance keeps the case the check exists to permit —
201    /// a covariance or Gram matrix assembled as `XᵀX / n` in `f32`, which
202    /// is symmetric in intent and asymmetric in the last few bits — while
203    /// refusing a transpose that was actually missed.
204    pub fn eigh(&self) -> Result<(Tensor, Tensor)> {
205        square_matrix_dims(self, "eigh")?;
206        check_symmetric(self, "eigh")?;
207        dispatch_cpu_fallback(
208            self,
209            |be, t| be.eigh(t),
210            |be, (w, v)| Ok((be.upload(&w)?, be.upload(&v)?)),
211        )
212    }
213}
214
215/// How far from symmetric an [`Tensor::eigh`] input may be, relative to
216/// its own largest magnitude.
217///
218/// `1e-5` is the bound the CPU↔GPU parity suite already uses for
219/// elementwise disagreement, so it is the project's existing answer to
220/// "how much floating-point drift is not a bug". A matrix that has
221/// accumulated more asymmetry than the backends disagree by has a real
222/// problem, not a rounding one.
223pub const EIGH_SYMMETRY_TOL: f32 = 1e-5;
224
225/// Refuse a matrix that is not symmetric to within [`EIGH_SYMMETRY_TOL`].
226///
227/// Reported like every other precondition in this module: the batch index
228/// so that a `[.., n, n]` input names *which* matrix is wrong rather than
229/// the first one, and the worst pair so the caller can see how far off it
230/// is rather than only that it is off.
231fn check_symmetric(t: &Tensor, op: &'static str) -> Result<()> {
232    let (batch, n) = square_matrix_dims(t, op)?;
233    if n < 2 {
234        // A 0x0 or 1x1 matrix is symmetric by construction, and the loop
235        // below would read nothing. Say so rather than relying on it.
236        return Ok(());
237    }
238    // Host-side and in f32: this reads the values once to compare them,
239    // and a GPU tensor has to come down for the Jacobi sweep anyway.
240    let a = t
241        .to_device(Device::Cpu)?
242        .to_dtype(DType::F32)?
243        .to_vec_f32()?;
244    let rank = t.dims().len();
245    for b in 0..batch {
246        let m = &a[b * n * n..(b + 1) * n * n];
247        let scale = m.iter().fold(0.0f32, |acc, v| acc.max(v.abs()));
248        let mut worst = (0usize, 0usize, 0.0f32);
249        for i in 0..n {
250            for j in (i + 1)..n {
251                let d = (m[i * n + j] - m[j * n + i]).abs();
252                if d > worst.2 {
253                    worst = (i, j, d);
254                }
255            }
256        }
257        // Relative to the matrix's own magnitude. An absolute bound would
258        // refuse a well-formed matrix scaled up and accept a badly-formed
259        // one scaled down.
260        let bound = EIGH_SYMMETRY_TOL * scale.max(f32::MIN_POSITIVE);
261        if worst.2 > bound {
262            let (i, j, d) = worst;
263            return Err(Error::InvalidArgument {
264                op,
265                detail: format!(
266                    "matrix {b} is not symmetric (|a[{i}][{j}] - a[{j}][{i}]| = {d:e}, \
267                     tolerance {bound:e}); {op} needs a symmetric input — if that is \
268                     what you meant, symmetrize it explicitly with \
269                     a.add(&a.transpose({d0}, {d1})?)?.mul_scalar(0.5)?",
270                    d0 = rank - 2,
271                    d1 = rank - 1,
272                ),
273            });
274        }
275    }
276    Ok(())
277}