Skip to main content

legume_numeric/matrix/
dmatrix_top_eigen.rs

1//! Leading algebraic eigenpairs of a symmetric matrix by Chebyshev-filtered
2//! block subspace iteration; see [`SymmetricTopEigen`].
3
4use crate::matrix::rand_util::{mix_seed, name_seed};
5use crate::matrix::traits::*;
6use nalgebra::{DMatrix, DVector};
7use num_traits::Float;
8use rayon::prelude::*;
9
10/// Fixed seed for the random start columns and the Lanczos start vector, so
11/// a call is reproducible run to run.
12const TOP_EIGEN_SEED: u64 = 0x544F_5045_4947_454E; // "TOPEIGEN"
13
14/// Lanczos steps used to bound the spectrum before iterating.
15const LANCZOS_STEPS: usize = 40;
16
17/// Fraction of the spectrum's width the damped interval reaches below the
18/// Lanczos estimate of `λ_min`.
19const LOWER_MARGIN: f64 = 0.05;
20
21/// Rows of the product one thread computes, at least.
22const MIN_ROWS_PER_TASK: usize = 64;
23
24/// The scalar the solver works in.
25trait EigScalar: nalgebra::RealField + Float + Copy + Send + Sync {}
26impl<T: nalgebra::RealField + Float + Copy + Send + Sync> EigScalar for T {}
27
28fn to_f64<T: EigScalar>(x: T) -> f64 {
29    num_traits::ToPrimitive::to_f64(&x).unwrap_or(f64::NAN)
30}
31
32fn from_f64<T: EigScalar>(x: f64) -> T {
33    <T as num_traits::NumCast>::from(x).expect("f64 fits the scalar")
34}
35
36/// What the solver needs of the symmetric matrix `A`.
37trait SymOp<T: EigScalar> {
38    fn dim(&self) -> usize;
39    /// `A · X`.
40    fn apply(&self, x: &DMatrix<T>) -> anyhow::Result<DMatrix<T>>;
41    /// `A` itself, for the dense fallback.
42    fn dense(&self) -> anyhow::Result<DMatrix<T>>;
43}
44
45/// `A · X`, one block of output rows per task, each a blocked GEMM of
46/// `A[j..j+w, :]` with `X` (nalgebra's own product is single-threaded).
47fn sym_times<T: EigScalar>(a: &DMatrix<T>, x: &DMatrix<T>) -> DMatrix<T> {
48    let n = a.ncols();
49    let block = n
50        .div_ceil(rayon::current_num_threads())
51        .max(MIN_ROWS_PER_TASK);
52    let starts: Vec<usize> = (0..n).step_by(block).collect();
53    let parts: Vec<DMatrix<T>> = starts
54        .par_iter()
55        .map(|&j| a.rows(j, block.min(n - j)) * x)
56        .collect();
57    let mut out = DMatrix::zeros(n, x.ncols());
58    for (&j, part) in starts.iter().zip(&parts) {
59        out.rows_mut(j, part.nrows()).copy_from(part);
60    }
61    out
62}
63
64impl<T: EigScalar> SymOp<T> for DMatrix<T> {
65    fn dim(&self) -> usize {
66        self.nrows()
67    }
68    fn apply(&self, x: &DMatrix<T>) -> anyhow::Result<DMatrix<T>> {
69        Ok(sym_times(self, x))
70    }
71    fn dense(&self) -> anyhow::Result<DMatrix<T>> {
72        Ok(self.clone())
73    }
74}
75
76/// Eigenpairs of a small symmetric matrix, sorted by descending value.
77fn sorted_eigen<T: EigScalar>(h: DMatrix<T>) -> (DVector<T>, DMatrix<T>) {
78    let eig = h.symmetric_eigen();
79    let mut order: Vec<usize> = (0..eig.eigenvalues.len()).collect();
80    order.sort_by(|&i, &j| {
81        eig.eigenvalues[j]
82            .partial_cmp(&eig.eigenvalues[i])
83            .unwrap_or(std::cmp::Ordering::Equal)
84    });
85    let values = DVector::from_iterator(order.len(), order.iter().map(|&i| eig.eigenvalues[i]));
86    let vectors = DMatrix::from_columns(
87        &order
88            .iter()
89            .map(|&i| eig.eigenvectors.column(i))
90            .collect::<Vec<_>>(),
91    );
92    (values, vectors)
93}
94
95/// Remove from `w` its components along the orthonormal columns of `basis`,
96/// twice, as one pass of classical Gram–Schmidt loses orthogonality.
97fn project_out<T: EigScalar>(w: &mut DMatrix<T>, basis: nalgebra::DMatrixView<T>) {
98    for _ in 0..2 {
99        let coef = basis.tr_mul(w);
100        *w -= basis * coef;
101    }
102}
103
104/// Lower and upper bounds on the spectrum of `A` from a Lanczos run with full
105/// reorthogonalisation. Each extreme Ritz value `θ` is widened by its
106/// residual `|β s|`, which an eigenvalue lies within; Lanczos finds the
107/// extremes first, so these bracket the spectrum. On breakdown (an invariant
108/// Krylov subspace) the run continues from a fresh orthogonal vector, which
109/// keeps the tridiagonal's eigenvalues eigenvalues of `A`.
110fn lanczos_bounds<T: EigScalar, D: SymOp<T>>(op: &D) -> anyhow::Result<(f64, f64)> {
111    let n = op.dim();
112    let steps = LANCZOS_STEPS.min(n);
113    let mut basis = DMatrix::<T>::zeros(n, steps);
114    let mut alpha = vec![0.0; steps];
115    let mut beta = vec![0.0; steps];
116    let mut v = DMatrix::<T>::rnorm_seeded(n, 1, name_seed(TOP_EIGEN_SEED, "lanczos"));
117    let mut len = 0;
118    // Largest `‖A v‖` seen, a lower estimate of `‖A‖`, to judge breakdown by.
119    let mut scale = 0.0f64;
120    while len < steps {
121        let nrm = v.norm();
122        if to_f64(nrm) <= f64::MIN_POSITIVE {
123            break;
124        }
125        v /= nrm;
126        basis.set_column(len, &v.column(0));
127        let mut w = op.apply(&v)?;
128        alpha[len] = to_f64(v.column(0).dot(&w.column(0)));
129        scale = scale.max(to_f64(w.norm()));
130        anyhow::ensure!(
131            alpha[len].is_finite() && scale.is_finite(),
132            "top_eigen: the matrix has non-finite entries"
133        );
134        // Full reorthogonalisation against every basis vector.
135        project_out(&mut w, basis.columns(0, len + 1));
136        len += 1;
137        let b = to_f64(w.norm());
138        let breakdown = b <= 1e3 * to_f64(T::epsilon()) * scale.max(f64::MIN_POSITIVE);
139        beta[len - 1] = if breakdown { 0.0 } else { b };
140        if breakdown && len < steps {
141            // Restart orthogonally to the basis, with β = 0.
142            let seed = mix_seed(name_seed(TOP_EIGEN_SEED, "lanczos"), len as u64);
143            w = DMatrix::<T>::rnorm_seeded(n, 1, seed);
144            project_out(&mut w, basis.columns(0, len));
145        }
146        v = w;
147    }
148    anyhow::ensure!(len > 0, "Lanczos found no direction");
149    let mut t = DMatrix::<f64>::zeros(len, len);
150    for i in 0..len {
151        t[(i, i)] = alpha[i];
152        if i + 1 < len {
153            t[(i, i + 1)] = beta[i];
154            t[(i + 1, i)] = beta[i];
155        }
156    }
157    let (theta, s) = sorted_eigen(t);
158    let resid = |i: usize| (beta[len - 1] * s[(len - 1, i)]).abs();
159    let hi = theta[0] + resid(0);
160    let lo = theta[len - 1] - resid(len - 1);
161    Ok((lo, hi))
162}
163
164/// The block after Rayleigh–Ritz: orthonormal Ritz vectors `v`, `A v`, and
165/// the Ritz values in descending order.
166struct Ritz<T: nalgebra::Scalar> {
167    v: DMatrix<T>,
168    av: DMatrix<T>,
169    theta: DVector<T>,
170}
171
172/// Orthonormalise `x` and project `A` onto its span.
173fn rayleigh_ritz<T: EigScalar, D: SymOp<T>>(op: &D, x: DMatrix<T>) -> anyhow::Result<Ritz<T>> {
174    let q = x.qr().q();
175    let aq = op.apply(&q)?;
176    let h = q.transpose() * &aq;
177    let h = (&h + h.transpose()) * from_f64::<T>(0.5);
178    let (theta, s) = sorted_eigen(h);
179    Ok(Ritz {
180        v: &q * &s,
181        av: &aq * &s,
182        theta,
183    })
184}
185
186/// `p(A) · V` for the degree-`m` Chebyshev polynomial that is bounded by 1
187/// on `[c − e, c + e]` and is 1 at `c + e τ`, `τ > 1`, by the scaled
188/// three-term recurrence (Zhou and Saad 2007), so nothing overflows. `av`
189/// is `A V`, which saves the first product.
190fn chebyshev_filter<T: EigScalar, D: SymOp<T>>(
191    op: &D,
192    ritz: &Ritz<T>,
193    m: usize,
194    c: f64,
195    e: f64,
196    tau: f64,
197) -> anyhow::Result<DMatrix<T>> {
198    let mut sigma = 1.0 / tau;
199    let mut prev = ritz.v.clone();
200    let mut cur = (&ritz.av - &ritz.v * from_f64::<T>(c)) * from_f64::<T>(sigma / e);
201    for _ in 1..m {
202        let next_sigma = 1.0 / (2.0 * tau - sigma);
203        let mut next = op.apply(&cur)?;
204        next -= &cur * from_f64::<T>(c);
205        next *= from_f64::<T>(2.0 * next_sigma / e);
206        next -= prev * from_f64::<T>(sigma * next_sigma);
207        prev = cur;
208        cur = next;
209        sigma = next_sigma;
210    }
211    Ok(cur)
212}
213
214/// Flip each column so its largest-magnitude entry is positive, which pins
215/// the sign an eigenvector is otherwise free to take.
216fn fix_signs<T: EigScalar>(v: &mut DMatrix<T>) {
217    for mut col in v.column_iter_mut() {
218        if col.nrows() > 0 && col[col.iamax()] < T::zero() {
219            col.neg_mut();
220        }
221    }
222}
223
224/// The first `rank` pairs, signs fixed.
225fn leading<T: EigScalar>(
226    values: &DVector<T>,
227    vectors: &DMatrix<T>,
228    rank: usize,
229    iters: usize,
230    converged: bool,
231) -> TopEigen<DMatrix<T>, DVector<T>> {
232    let mut vectors = vectors.columns(0, rank).into_owned();
233    fix_signs(&mut vectors);
234    TopEigen {
235        values: values.rows(0, rank).into_owned(),
236        vectors,
237        iters,
238        converged,
239    }
240}
241
242/// The top `rank` pairs of `op`.
243fn top_eigen_op<T: EigScalar, D: SymOp<T>>(
244    op: &D,
245    rank: usize,
246    args: &TopEigenArgs,
247    warm_start: Option<&DMatrix<T>>,
248) -> anyhow::Result<TopEigen<DMatrix<T>, DVector<T>>> {
249    let n = op.dim();
250    let rank = rank.min(n);
251    if rank == 0 {
252        return Ok(leading(
253            &DVector::zeros(0),
254            &DMatrix::zeros(n, 0),
255            0,
256            0,
257            true,
258        ));
259    }
260    let k = rank.saturating_add(args.oversample).min(n);
261
262    // A block over half the dimension costs more per cycle than a dense
263    // solve does in all.
264    if 2 * k >= n {
265        let (values, vectors) = sorted_eigen(op.dense()?);
266        return Ok(leading(&values, &vectors, rank, 0, true));
267    }
268
269    let (mut lo, hi) = lanczos_bounds(op)?;
270    let norm = lo.abs().max(hi.abs()).max(f64::MIN_POSITIVE);
271    // Keep the block's conditioning within √ε: a column amplified past that
272    // relative to another loses the other to rounding in the QR.
273    let growth_cap = (2.0 / to_f64(T::epsilon()).sqrt()).ln();
274
275    let mut x = DMatrix::<T>::rnorm_seeded(n, k, TOP_EIGEN_SEED);
276    if let Some(w) = warm_start {
277        anyhow::ensure!(
278            w.nrows() == n,
279            "warm start has {} rows, the matrix {n}",
280            w.nrows()
281        );
282        let wc = w.ncols().min(k);
283        x.columns_mut(0, wc).copy_from(&w.columns(0, wc));
284    }
285    let mut ritz = rayleigh_ritz(op, x)?;
286    let mut iters = 0;
287    let converged = loop {
288        let worst = (0..rank)
289            .map(|i| to_f64((ritz.av.column(i) - ritz.v.column(i) * ritz.theta[i]).norm()))
290            .fold(0.0f64, f64::max);
291        if worst <= args.tol * norm {
292            break true;
293        }
294        if iters >= args.max_iters {
295            break false;
296        }
297
298        // Damp [a, b], everything up to the block's lowest Ritz value, and
299        // amplify above it, scaled to 1 at the top of the spectrum.
300        // The Lanczos bound on λ_min is an estimate, and a component below
301        // `a` would be amplified, not damped, so leave a margin: widening
302        // the interval by 5% costs little convergence.
303        let (t_hi, t_lo) = (to_f64(ritz.theta[0]), to_f64(ritz.theta[k - 1]));
304        lo = lo.min(t_lo);
305        let a = lo - LOWER_MARGIN * (hi - lo).max(norm * f64::EPSILON);
306        let b = t_lo.max(a + 1e-3 * (hi.max(t_hi) - a));
307        let top = hi.max(t_hi).max(b + (b - a));
308        let (c, e) = ((b + a) / 2.0, (b - a) / 2.0);
309        let tau = (top - c) / e;
310        let lead = ((t_hi - c) / e).max(1.0 + 1e-12);
311        let degree =
312            ((growth_cap / lead.acosh()).floor() as usize).clamp(1, args.max_degree.max(1));
313
314        let x = chebyshev_filter(op, &ritz, degree, c, e, tau)?;
315        ritz = rayleigh_ritz(op, x)?;
316        iters += 1;
317    };
318
319    if !converged {
320        log::warn!(
321            "top_eigen: {rank} pairs not converged to tol {} in {iters} iterations",
322            args.tol
323        );
324    }
325    Ok(leading(&ritz.theta, &ritz.v, rank, iters, converged))
326}
327
328// The bound is `EigScalar` spelled out: a private trait cannot bound a
329// public impl.
330impl<T> SymmetricTopEigen for DMatrix<T>
331where
332    T: nalgebra::RealField + Float + Copy + Send + Sync,
333{
334    type Mat = DMatrix<T>;
335    type DVec = DVector<T>;
336
337    fn top_eigen_with(
338        &self,
339        rank: usize,
340        args: &TopEigenArgs,
341        warm_start: Option<&Self::Mat>,
342    ) -> anyhow::Result<TopEigen<Self::Mat, Self::DVec>> {
343        anyhow::ensure!(
344            self.is_square(),
345            "top_eigen needs a square matrix, got {} x {}",
346            self.nrows(),
347            self.ncols()
348        );
349        top_eigen_op(self, rank, args, warm_start)
350    }
351}
352
353#[cfg(feature = "tensor")]
354mod tensor {
355    use super::*;
356    use candle_core::{DType, Tensor};
357
358    /// A 2-D tensor as the operator: products run in candle, on the tensor's
359    /// own device and threads, and only the `n × k` block crosses over.
360    struct TensorOp<'a>(&'a Tensor);
361
362    impl<T> SymOp<T> for TensorOp<'_>
363    where
364        T: EigScalar + candle_core::WithDType,
365    {
366        fn dim(&self) -> usize {
367            self.0.dims()[0]
368        }
369        /// The column-major `X` is, read row-major, `Xᵀ`; `Xᵀ A = (A X)ᵀ`
370        /// by symmetry, and its row-major buffer is `A X` column-major, so
371        /// no transposes are needed either way.
372        fn apply(&self, x: &DMatrix<T>) -> anyhow::Result<DMatrix<T>> {
373            let (n, k) = x.shape();
374            let xt = Tensor::from_slice(x.as_slice(), (k, n), self.0.device())?;
375            let axt = xt.matmul(self.0)?;
376            Ok(DMatrix::from_vec(n, k, axt.flatten_all()?.to_vec1()?))
377        }
378        fn dense(&self) -> anyhow::Result<DMatrix<T>> {
379            DMatrix::<T>::from_tensor(self.0)
380        }
381    }
382
383    fn run<T>(
384        a: &Tensor,
385        rank: usize,
386        args: &TopEigenArgs,
387        warm_start: Option<&Tensor>,
388    ) -> anyhow::Result<TopEigen<Tensor, Tensor>>
389    where
390        T: EigScalar + candle_core::WithDType,
391    {
392        let warm = warm_start
393            .map(|w| DMatrix::<T>::from_tensor(&w.to_dtype(a.dtype())?))
394            .transpose()?;
395        let out = top_eigen_op::<T, _>(&TensorOp(a), rank, args, warm.as_ref())?;
396        Ok(TopEigen {
397            values: Tensor::from_slice(out.values.as_slice(), out.values.len(), a.device())?,
398            vectors: out.vectors.to_tensor(a.device())?.contiguous()?,
399            iters: out.iters,
400            converged: out.converged,
401        })
402    }
403
404    impl SymmetricTopEigen for Tensor {
405        type Mat = Tensor;
406        type DVec = Tensor;
407
408        /// Runs in the tensor's dtype (`f32` or `f64`); the results are
409        /// tensors of that dtype on its device.
410        fn top_eigen_with(
411            &self,
412            rank: usize,
413            args: &TopEigenArgs,
414            warm_start: Option<&Self::Mat>,
415        ) -> anyhow::Result<TopEigen<Self::Mat, Self::DVec>> {
416            let dims = self.dims();
417            anyhow::ensure!(
418                dims.len() == 2 && dims[0] == dims[1],
419                "top_eigen needs a square 2-D tensor, got {dims:?}"
420            );
421            match self.dtype() {
422                DType::F32 => run::<f32>(self, rank, args, warm_start),
423                DType::F64 => run::<f64>(self, rank, args, warm_start),
424                dt => anyhow::bail!("top_eigen supports f32 and f64 tensors, got {dt:?}"),
425            }
426        }
427    }
428}
429
430#[cfg(feature = "ndarray")]
431mod ndarray_impl {
432    use super::*;
433    use ndarray::{Array1, Array2, ShapeBuilder};
434
435    fn to_dmatrix<T: EigScalar>(a: &Array2<T>) -> DMatrix<T> {
436        let (n, m) = a.dim();
437        DMatrix::from_fn(n, m, |i, j| a[(i, j)])
438    }
439
440    fn to_array<T: EigScalar>(m: &DMatrix<T>) -> Array2<T> {
441        // Column-major storage is Fortran order.
442        Array2::from_shape_vec(m.shape().f(), m.as_slice().to_vec())
443            .expect("shape matches the buffer")
444    }
445
446    /// Copies the matrix into a `DMatrix` once (`n²`, small next to the
447    /// iteration) and runs the dense solver: its products are parallel,
448    /// and ndarray's `blas` feature links no BLAS of its own.
449    // The bound is `EigScalar` spelled out: a private trait cannot bound a
450    // public impl.
451    impl<T> SymmetricTopEigen for Array2<T>
452    where
453        T: nalgebra::RealField + Float + Copy + Send + Sync,
454    {
455        type Mat = Array2<T>;
456        type DVec = Array1<T>;
457
458        fn top_eigen_with(
459            &self,
460            rank: usize,
461            args: &TopEigenArgs,
462            warm_start: Option<&Self::Mat>,
463        ) -> anyhow::Result<TopEigen<Self::Mat, Self::DVec>> {
464            let warm = warm_start.map(to_dmatrix);
465            let out = to_dmatrix(self).top_eigen_with(rank, args, warm.as_ref())?;
466            Ok(TopEigen {
467                values: Array1::from_vec(out.values.as_slice().to_vec()),
468                vectors: to_array(&out.vectors),
469                iters: out.iters,
470                converged: out.converged,
471            })
472        }
473    }
474}
475
476#[cfg(test)]
477#[path = "dmatrix_top_eigen_tests.rs"]
478mod tests;