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}