Skip to main content

apex_solver/linalg/sparse/
normal_eq.rs

1//! Parallel normal-equation formation: `H = JᵀJ` and `g = Jᵀr`.
2//!
3//! The sparsity patterns of `Jᵀ`, `JᵀJ` and the value permutation linking `Jᵀ`
4//! to `J` are static for the lifetime of a solve, so they are computed once and
5//! cached. Each evaluation then runs as:
6//!
7//! 1. a parallel gather of `Jᵀ` values from `J` values (rayon, O(nnz)),
8//! 2. faer's parallel sparse×sparse numeric kernel into the cached product
9//!    pattern,
10//! 3. faer's parallel sparse×dense kernel for the gradient.
11//!
12//! This replaces the previous per-call `transpose().to_col_major().mul(j)`
13//! chain, which materialized the transposed Jacobian serially and recomputed
14//! the symbolic product on every call. The numeric kernels dispatch through
15//! `Par::global()`, so they run on the rayon pool (faer's `rayon` feature).
16//!
17//! # Invariant
18//!
19//! The cache is keyed on the Jacobian's sparsity pattern, which must not change
20//! between `matches` and `compute`. Within one optimization this holds by
21//! construction — the linearizer rebuilds `J` from the same symbolic structure
22//! every iteration.
23
24use dyn_stack::MemStack;
25use faer::{
26    Accum, Mat, get_global_parallelism,
27    prelude::Reborrow,
28    sparse::linalg::matmul::{
29        SparseMatMulInfo, sparse_dense_matmul, sparse_sparse_matmul_numeric,
30        sparse_sparse_matmul_numeric_scratch, sparse_sparse_matmul_symbolic,
31    },
32    sparse::{
33        SparseColMat, SparseColMatMut, SparseColMatRef, SymbolicSparseColMat,
34        SymbolicSparseColMatRef,
35    },
36};
37use rayon::prelude::*;
38use std::mem::MaybeUninit;
39
40use crate::error::ErrorLogging;
41use crate::linalg::{Damping, LinAlgError, LinAlgResult};
42
43/// Cached symbolic machinery for forming the normal equations of a fixed
44/// Jacobian sparsity pattern.
45pub struct NormalEquationsCache {
46    /// Sparsity of `Jᵀ` in CSC layout.
47    jt_pattern: SymbolicSparseColMat<usize>,
48    /// `jt_values[k] = j_values[value_perm[k]]`.
49    value_perm: Vec<usize>,
50    /// Sparsity of the product `JᵀJ`.
51    product_symbolic: SymbolicSparseColMat<usize>,
52    /// Per-column flop counts used by faer to partition the numeric matmul.
53    product_info: SparseMatMulInfo,
54    /// Position of the diagonal entry within each column of `JᵀJ`
55    /// (`None` for structurally empty diagonals).
56    diag_pos: Vec<Option<usize>>,
57    /// Pattern of `JᵀJ` widened so every diagonal entry exists, together with
58    /// the mapping from the product's values into it.
59    ///
60    /// `None` when the product already carries a full diagonal, which is the
61    /// common case. A column of `J` that is entirely zero — a fully fixed
62    /// variable, as gauge fixing produces — leaves `JᵀJ` with no diagonal entry
63    /// there, and damping has to *create* it.
64    augmented: Option<AugmentedDiagonal>,
65    /// Fingerprint of the pattern the cache was built from.
66    ///
67    /// Keyed on the full [`PatternFingerprint`](super::pattern::PatternFingerprint),
68    /// not just dimensions: an equal-`nnz` permutation must rebuild the
69    /// symbolic product and permutation, never reuse them.
70    pattern: super::pattern::PatternFingerprint,
71    /// Reusable `Jᵀ` value buffer.
72    jt_values: Vec<f64>,
73    /// Reusable `JᵀJ` value buffer.
74    hessian_values: Vec<f64>,
75    /// Reusable scratch for faer's numeric kernel.
76    stack_buf: Vec<MaybeUninit<u8>>,
77}
78
79/// The undamped normal equations of one linearization.
80pub struct NormalEquations {
81    pub hessian: SparseColMat<usize, f64>,
82    /// `Jᵀ·r` (positive; consumers negate where their sign convention demands).
83    pub gradient: Mat<f64>,
84}
85
86/// Holder for an optional [`NormalEquationsCache`] that stays `Clone`/`Debug`
87/// for solver structs: cloning resets the cache (it rebuilds lazily from the
88/// next solve's sparsity pattern) and Debug prints a placeholder.
89#[derive(Default)]
90pub struct LazyNormalEquations(Option<NormalEquationsCache>);
91
92impl Clone for LazyNormalEquations {
93    fn clone(&self) -> Self {
94        Self(None)
95    }
96}
97
98impl std::fmt::Debug for LazyNormalEquations {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str("LazyNormalEquations(rebuilds on next solve)")
101    }
102}
103
104impl LazyNormalEquations {
105    /// Rebuild the cache if missing or if `jacobians`' pattern changed.
106    pub fn ensure(&mut self, jacobians: &SparseColMat<usize, f64>) -> LinAlgResult<()> {
107        if self.0.as_ref().is_none_or(|c| !c.matches(jacobians)) {
108            self.0 = Some(NormalEquationsCache::try_new(jacobians)?);
109        }
110        Ok(())
111    }
112
113    /// Form `H = JᵀJ` and `g = Jᵀr`, rebuilding the cache when needed.
114    pub fn compute(
115        &mut self,
116        residuals: &Mat<f64>,
117        jacobians: &SparseColMat<usize, f64>,
118    ) -> LinAlgResult<NormalEquations> {
119        self.ensure(jacobians)?;
120        match self.0.as_mut() {
121            Some(cache) => cache.compute(residuals, jacobians),
122            None => Err(LinAlgError::InvalidState(
123                "normal equations cache not initialized".to_string(),
124            )),
125        }
126    }
127
128    /// `H + λ·D` on the cached product pattern — see
129    /// [`NormalEquationsCache::damped_hessian`].
130    pub fn damped_hessian(&self, damping: &Damping) -> LinAlgResult<SparseColMat<usize, f64>> {
131        match self.0.as_ref() {
132            Some(cache) => cache.damped_hessian(damping),
133            None => Err(LinAlgError::InvalidState(
134                "normal equations cache not initialized".to_string(),
135            )),
136        }
137    }
138
139    /// Sparsity of `JᵀJ`, for building compatible factorization symbolics.
140    pub fn product_symbolic(&self) -> LinAlgResult<SymbolicSparseColMat<usize>> {
141        match self.0.as_ref() {
142            Some(cache) => Ok(cache.product_symbolic()),
143            None => Err(LinAlgError::InvalidState(
144                "normal equations cache not initialized".to_string(),
145            )),
146        }
147    }
148}
149
150impl NormalEquationsCache {
151    /// Build the cache for a Jacobian sparsity pattern.
152    pub fn try_new(jacobian: &SparseColMat<usize, f64>) -> LinAlgResult<Self> {
153        let sym = jacobian.as_ref().symbolic();
154        let (jt_pattern, value_perm) = transpose_symbolic(sym)?;
155        let (product_symbolic, product_info) = sparse_sparse_matmul_symbolic(jt_pattern.rb(), sym)
156            .map_err(|e| {
157                LinAlgError::MatrixConversion(format!("Symbolic JᵀJ product failed: {e:?}"))
158                    .log_with_source(e)
159            })?;
160
161        let ncols = jacobian.ncols();
162        let diag_pos: Vec<Option<usize>> = (0..ncols)
163            .map(|col| {
164                // Absolute index into the flat CSC value array.
165                let col_start = product_symbolic.col_range(col).start;
166                product_symbolic
167                    .row_idx_of_col_raw(col)
168                    .iter()
169                    .position(|&r| r == col)
170                    .map(|local| col_start + local)
171            })
172            .collect();
173
174        let scratch_req = sparse_sparse_matmul_numeric_scratch::<usize, f64>(
175            product_symbolic.rb(),
176            get_global_parallelism(),
177        );
178
179        let augmented = if diag_pos.iter().any(Option::is_none) {
180            Some(AugmentedDiagonal::build(&product_symbolic)?)
181        } else {
182            None
183        };
184
185        Ok(Self {
186            jt_values: vec![0.0; jt_pattern.compute_nnz()],
187            hessian_values: vec![0.0; product_symbolic.compute_nnz()],
188            stack_buf: vec![MaybeUninit::uninit(); scratch_req.unaligned_bytes_required()],
189            jt_pattern,
190            value_perm,
191            product_symbolic,
192            product_info,
193            diag_pos,
194            augmented,
195            pattern: super::pattern::PatternFingerprint::of(jacobian),
196        })
197    }
198
199    /// Whether the cache still describes `jacobian`'s sparsity pattern.
200    ///
201    /// Full-pattern equality: dimensions and nonzero count reject fast, and
202    /// the embedded hash rejects equal-`nnz` permutations that would
203    /// otherwise reuse a stale symbolic product and value permutation.
204    pub fn matches(&self, jacobian: &SparseColMat<usize, f64>) -> bool {
205        self.pattern == super::pattern::PatternFingerprint::of(jacobian)
206    }
207
208    /// Sparsity of `JᵀJ`, for building factorizations compatible with `compute`.
209    pub fn product_symbolic(&self) -> SymbolicSparseColMat<usize> {
210        self.product_symbolic.clone()
211    }
212
213    /// Form `H = JᵀJ` and `g = Jᵀr` with parallel faer kernels.
214    pub fn compute(
215        &mut self,
216        residuals: &Mat<f64>,
217        jacobian: &SparseColMat<usize, f64>,
218    ) -> LinAlgResult<NormalEquations> {
219        let j_values = jacobian.as_ref().val();
220        let perm = &self.value_perm;
221
222        // Gather Jᵀ values from J — a pure permutation, run in parallel.
223        self.jt_values
224            .par_iter_mut()
225            .zip(perm.par_iter())
226            .for_each(|(v, &src)| *v = j_values[src]);
227
228        // Parallel sparse×sparse numeric product into the cached pattern.
229        let hessian = {
230            let mut stack_buf = std::mem::take(&mut self.stack_buf);
231            {
232                let stack = MemStack::new(&mut stack_buf);
233                let dst =
234                    SparseColMatMut::new(self.product_symbolic.rb(), &mut self.hessian_values);
235                let jt = SparseColMatRef::new(self.jt_pattern.rb(), &self.jt_values);
236                sparse_sparse_matmul_numeric(
237                    dst,
238                    Accum::Replace,
239                    jt,
240                    jacobian.as_ref(),
241                    1.0,
242                    &self.product_info,
243                    get_global_parallelism(),
244                    stack,
245                );
246            }
247            self.stack_buf = stack_buf;
248            SparseColMat::new(self.product_symbolic.clone(), self.hessian_values.clone())
249        };
250
251        // Parallel sparse×dense product for the gradient: g = Jᵀ·r.
252        let mut gradient = Mat::<f64>::zeros(self.pattern.ncols(), 1);
253        {
254            let jt = SparseColMatRef::new(self.jt_pattern.rb(), &self.jt_values);
255            sparse_dense_matmul(
256                gradient.as_mut(),
257                Accum::Replace,
258                jt,
259                residuals.as_ref(),
260                1.0,
261                get_global_parallelism(),
262            );
263        }
264
265        Ok(NormalEquations { hessian, gradient })
266    }
267
268    /// `H + λ·D` as an owned sparse matrix on the cached product pattern.
269    ///
270    /// `D_jj = clamp(H_jj, min_diagonal, max_diagonal)` — see [`Damping`]. Each
271    /// diagonal position is visited exactly once, so `values[*pos]` still holds
272    /// the un-damped `H_jj` when the clamp reads it.
273    ///
274    /// When `JᵀJ` is missing diagonal entries — a fully fixed variable
275    /// contributes an all-zero column, so gauge fixing produces exactly that —
276    /// the result is built on a widened pattern that carries the whole
277    /// diagonal. That pattern is computed once, not per solve.
278    ///
279    /// Callers holding a cached factorization symbolic must use
280    /// [`Self::product_symbolic_for_damping`] to build it, so the two agree.
281    pub fn damped_hessian(&self, damping: &Damping) -> LinAlgResult<SparseColMat<usize, f64>> {
282        let Some(aug) = &self.augmented else {
283            let mut values = self.hessian_values.clone();
284            for pos in self.diag_pos.iter().flatten() {
285                values[*pos] += damping.diagonal_term(values[*pos]);
286            }
287            return Ok(SparseColMat::new(self.product_symbolic.clone(), values));
288        };
289
290        let mut values = vec![0.0; aug.nnz];
291        for (base, &target) in aug.from_base.iter().enumerate() {
292            values[target] = self.hessian_values[base];
293        }
294        for &pos in &aug.diag_pos {
295            values[pos] += damping.diagonal_term(values[pos]);
296        }
297        Ok(SparseColMat::new(aug.symbolic.clone(), values))
298    }
299
300    /// The pattern [`Self::damped_hessian`] produces.
301    ///
302    /// Equal to [`Self::product_symbolic`] unless the diagonal had to be
303    /// widened; factorization symbolics for the damped system must be built
304    /// from this one.
305    pub fn product_symbolic_for_damping(&self) -> SymbolicSparseColMat<usize> {
306        match &self.augmented {
307            Some(aug) => aug.symbolic.clone(),
308            None => self.product_symbolic.clone(),
309        }
310    }
311}
312
313/// `JᵀJ`'s pattern widened so every diagonal entry is present.
314///
315/// Damping adds `λ·D` to the diagonal, so a structurally absent diagonal has to
316/// be created. Rebuilding the pattern every solve would be wasteful, so it is
317/// built once alongside the value mapping that scatters the product's values
318/// into it.
319struct AugmentedDiagonal {
320    symbolic: SymbolicSparseColMat<usize>,
321    /// `from_base[k]` is where product value `k` lands in the widened array.
322    from_base: Vec<usize>,
323    /// Offset of each column's diagonal entry in the widened array.
324    diag_pos: Vec<usize>,
325    nnz: usize,
326}
327
328impl AugmentedDiagonal {
329    fn build(product: &SymbolicSparseColMat<usize>) -> LinAlgResult<Self> {
330        let ncols = product.ncols();
331        let nrows = product.nrows();
332
333        // Union of the product's entries with the full diagonal, per column.
334        let mut col_ptr = vec![0usize; ncols + 1];
335        let mut row_idx = Vec::with_capacity(product.compute_nnz() + ncols);
336        let mut from_base = vec![0usize; product.compute_nnz()];
337        let mut diag_pos = Vec::with_capacity(ncols);
338
339        for col in 0..ncols {
340            let base_start = product.col_range(col).start;
341            let rows = product.row_idx_of_col_raw(col);
342            let mut inserted_diag = false;
343
344            for (local, &row) in rows.iter().enumerate() {
345                if !inserted_diag && row > col {
346                    diag_pos.push(row_idx.len());
347                    row_idx.push(col);
348                    inserted_diag = true;
349                }
350                if row == col {
351                    diag_pos.push(row_idx.len());
352                    inserted_diag = true;
353                }
354                from_base[base_start + local] = row_idx.len();
355                row_idx.push(row);
356            }
357            if !inserted_diag {
358                diag_pos.push(row_idx.len());
359                row_idx.push(col);
360            }
361            col_ptr[col + 1] = row_idx.len();
362        }
363
364        let nnz = row_idx.len();
365        let symbolic = SymbolicSparseColMat::new_checked(nrows, ncols, col_ptr, None, row_idx);
366
367        Ok(Self {
368            symbolic,
369            from_base,
370            diag_pos,
371            nnz,
372        })
373    }
374}
375
376/// Transpose a CSC pattern, returning the transposed pattern together with the
377/// value permutation: `Jᵀ.values[k] == J.values[perm[k]]`.
378///
379/// Rows within each `Jᵀ` column come out sorted because the source columns of
380/// `J` are visited in ascending order; `J`'s own row indices need not be
381/// sorted.
382fn transpose_symbolic(
383    j: SymbolicSparseColMatRef<'_, usize>,
384) -> LinAlgResult<(SymbolicSparseColMat<usize>, Vec<usize>)> {
385    let (m, n) = (j.nrows(), j.ncols());
386    let col_ptr = j.col_ptr();
387    let row_idx = j.row_idx();
388    let nnz = row_idx.len();
389
390    // Column counts of Jᵀ are the row counts of J.
391    let mut t_col_ptr = vec![0usize; m + 1];
392    for &row in row_idx {
393        t_col_ptr[row + 1] += 1;
394    }
395    for i in 0..m {
396        t_col_ptr[i + 1] += t_col_ptr[i];
397    }
398
399    let mut t_row_idx = vec![0usize; nnz];
400    let mut perm = vec![0usize; nnz];
401    let mut next = t_col_ptr[..m].to_vec();
402    for col in 0..n {
403        let col_start = col_ptr[col];
404        let col_end = col_ptr[col + 1];
405        for (k, &row) in row_idx[col_start..col_end].iter().enumerate() {
406            let pos = next[row];
407            t_row_idx[pos] = col;
408            perm[pos] = col_start + k;
409            next[row] = pos + 1;
410        }
411    }
412
413    let pattern = SymbolicSparseColMat::new_checked(n, m, t_col_ptr, None, t_row_idx);
414    Ok((pattern, perm))
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use faer::sparse::Triplet;
421
422    type TestResult<T> = Result<T, Box<dyn std::error::Error>>;
423
424    /// Deterministic pseudo-random sparse matrix with ~40% fill.
425    fn sample_jacobian(m: usize, n: usize, seed: u64) -> TestResult<SparseColMat<usize, f64>> {
426        let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
427        let mut state = seed | 1;
428        for col in 0..n {
429            for row in 0..m {
430                state = state
431                    .wrapping_mul(6364136223846793005)
432                    .wrapping_add(1442695040888963407);
433                if (state >> 33) % 5 < 2 {
434                    let value = ((state >> 34) % 1000) as f64 / 250.0 - 2.0;
435                    triplets.push(Triplet::new(row, col, value));
436                }
437            }
438        }
439        Ok(SparseColMat::try_new_from_triplets(m, n, &triplets)?)
440    }
441
442    fn naive_hessian(j: &SparseColMat<usize, f64>) -> TestResult<SparseColMat<usize, f64>> {
443        use std::ops::Mul;
444        let jt = j
445            .as_ref()
446            .transpose()
447            .to_col_major()
448            .map_err(|e| format!("transpose to_col_major failed: {e:?}"))?;
449        Ok(jt.mul(j.as_ref()))
450    }
451
452    fn assert_hessians_close(a: &SparseColMat<usize, f64>, b: &SparseColMat<usize, f64>) {
453        assert_eq!(a.nrows(), b.nrows());
454        assert_eq!(a.ncols(), b.ncols());
455        for col in 0..a.ncols() {
456            let rows_a = a.symbolic().row_idx_of_col_raw(col);
457            let rows_b = b.symbolic().row_idx_of_col_raw(col);
458            assert_eq!(rows_a, rows_b, "col {col}: pattern mismatch");
459            for (va, vb) in a.val_of_col(col).iter().zip(b.val_of_col(col)) {
460                assert!(
461                    (va - vb).abs() < 1e-10 * (1.0 + va.abs()),
462                    "col {col}: {va} vs {vb}"
463                );
464            }
465        }
466    }
467
468    #[test]
469    fn test_hessian_and_gradient_match_naive() -> TestResult<()> {
470        let j = sample_jacobian(40, 25, 7)?;
471        let residuals = Mat::from_fn(40, 1, |i, _| ((i * 13) % 17) as f64 - 8.0);
472
473        let mut cache = NormalEquationsCache::try_new(&j)?;
474        let ne = cache.compute(&residuals, &j)?;
475
476        assert_hessians_close(&ne.hessian, &naive_hessian(&j)?);
477
478        use std::ops::Mul;
479        let jt = j
480            .as_ref()
481            .transpose()
482            .to_col_major()
483            .map_err(|e| format!("{e:?}"))?;
484        let expected_g = jt.mul(residuals.as_ref());
485        for i in 0..ne.gradient.nrows() {
486            assert!((ne.gradient[(i, 0)] - expected_g[(i, 0)]).abs() < 1e-10);
487        }
488        Ok(())
489    }
490
491    #[test]
492    fn test_compute_reusable_across_calls() -> TestResult<()> {
493        let j = sample_jacobian(30, 20, 42)?;
494        let residuals = Mat::from_fn(30, 1, |i, _| (i as f64) * 0.25);
495
496        let mut cache = NormalEquationsCache::try_new(&j)?;
497        let first = cache.compute(&residuals, &j)?;
498        // Second call overwrites every buffer — must reproduce the same H and g.
499        let second = cache.compute(&residuals, &j)?;
500        assert_hessians_close(&first.hessian, &second.hessian);
501        for i in 0..first.gradient.nrows() {
502            assert!((first.gradient[(i, 0)] - second.gradient[(i, 0)]).abs() < 1e-12);
503        }
504        Ok(())
505    }
506
507    #[test]
508    fn test_matches_detects_shape_change() -> TestResult<()> {
509        let j = sample_jacobian(30, 20, 1)?;
510        let cache = NormalEquationsCache::try_new(&j)?;
511        assert!(cache.matches(&j));
512
513        let other = sample_jacobian(30, 21, 1)?;
514        assert!(!cache.matches(&other));
515        Ok(())
516    }
517
518    #[test]
519    fn test_matches_rejects_equal_nnz_permutation() -> TestResult<()> {
520        // Same shape and same nnz, one entry relocated within its column: the
521        // old `(nrows, ncols, nnz)` triple reused the stale symbolic product
522        // and value permutation for this pattern.
523        let t = vec![
524            Triplet::new(0usize, 0usize, 1.0f64),
525            Triplet::new(1, 0, 2.0),
526            Triplet::new(1, 1, 3.0),
527            Triplet::new(2, 1, 4.0),
528        ];
529        let j = SparseColMat::try_new_from_triplets(3, 2, &t)?;
530        let cache = NormalEquationsCache::try_new(&j)?;
531        assert!(cache.matches(&j));
532
533        let moved = vec![
534            Triplet::new(0usize, 0usize, 1.0f64),
535            Triplet::new(1, 0, 2.0),
536            Triplet::new(0, 1, 3.0),
537            Triplet::new(2, 1, 4.0),
538        ];
539        let permuted = SparseColMat::try_new_from_triplets(3, 2, &moved)?;
540        assert_eq!(
541            permuted.as_ref().compute_nnz(),
542            j.as_ref().compute_nnz(),
543            "relocation must preserve nnz for the regression to be meaningful"
544        );
545        assert!(
546            !cache.matches(&permuted),
547            "an equal-nnz permutation must invalidate the cache"
548        );
549        Ok(())
550    }
551
552    #[test]
553    fn test_damped_hessian_adds_lambda_on_diagonal() -> TestResult<()> {
554        let j = sample_jacobian(20, 15, 99)?;
555        let residuals = Mat::zeros(20, 1);
556        let mut cache = NormalEquationsCache::try_new(&j)?;
557        let ne = cache.compute(&residuals, &j)?;
558
559        let damped = cache.damped_hessian(&Damping::identity(0.5))?;
560        for col in 0..damped.ncols() {
561            let rows = damped.symbolic().row_idx_of_col_raw(col);
562            let pos = rows
563                .iter()
564                .position(|&r| r == col)
565                .ok_or("missing diagonal in damped hessian")?;
566            let expected = ne.hessian.val_of_col(col)[pos] + 0.5;
567            assert!((damped.val_of_col(col)[pos] - expected).abs() < 1e-12);
568        }
569        Ok(())
570    }
571}