Skip to main content

diffsol_la/linear_solver/nalgebra/
lu.rs

1use nalgebra::linalg::{gauss_step, gauss_step_swap};
2use nalgebra::{Dim, Dyn, Matrix as NaMatrix, StorageMut};
3
4use crate::context::broadcast_batch;
5use crate::{
6    error::LaError, linear_solver_error, matrix::dense_nalgebra_serial::NalgebraMat, Context,
7    LinearOp, LinearSolver, Matrix, NalgebraContext, NalgebraScalar, NalgebraVec,
8};
9
10// `gauss_step`/`gauss_step_swap` are `#[doc(hidden)]` in nalgebra (not officially documented,
11// though `pub fn` and re-exported at the crate root) -- they are the exact elimination steps
12// `nalgebra::linalg::LU::new` calls internally.
13
14/// LU factorization state for a (possibly batched) [NalgebraMat], factorized and solved
15/// entirely in place: `a` is written to directly by `LinearOp::matrix_inplace` in
16/// [ReusableLU::set_linearisation] (no separate scratch matrix + clone, as
17/// `nalgebra::linalg::LU::new` would need), then decomposed in place, batch block by batch
18/// block.
19struct BatchLu<T: NalgebraScalar> {
20    a: NalgebraMat<T>,
21    /// Row-swap history per batch (replaces nalgebra's `PermutationSequence`), reused across
22    /// `factor` calls.
23    piv: Vec<Vec<(usize, usize)>>,
24}
25
26impl<T: NalgebraScalar> BatchLu<T> {
27    fn new(a: NalgebraMat<T>) -> Self {
28        let nbatch = a.context.nbatch();
29        Self {
30            a,
31            piv: vec![Vec::new(); nbatch],
32        }
33    }
34
35    fn nbatch(&self) -> usize {
36        self.piv.len()
37    }
38
39    fn logical_ncols(&self) -> usize {
40        self.a.data.ncols() / self.nbatch()
41    }
42
43    /// Factorize `self.a` in place, one square batch block at a time. Body copied from
44    /// `nalgebra::linalg::LU::new`, run against a mutable view of each block instead of an
45    /// owned matrix.
46    fn factor(&mut self) {
47        let ncols = self.logical_ncols();
48        for batch in 0..self.nbatch() {
49            let piv = &mut self.piv[batch];
50            piv.clear();
51            let mut block = self.a.data.columns_mut(batch * ncols, ncols);
52            for i in 0..ncols {
53                let p = block.view_range(i.., i).icamax() + i;
54                let diag = block[(p, i)];
55                if diag.is_zero() {
56                    continue;
57                }
58                if p != i {
59                    piv.push((i, p));
60                    block.columns_range_mut(..i).swap_rows(i, p);
61                    gauss_step_swap(&mut block, diag, i, p);
62                } else {
63                    gauss_step(&mut block, diag, i);
64                }
65            }
66        }
67    }
68
69    /// Solve `block(batch) * x = b` in place, `b` holding the input and receiving the
70    /// solution. Body copied from `nalgebra::linalg::LU::solve_mut`.
71    fn solve_block<C2: Dim, S2: StorageMut<T, Dyn, C2>>(
72        &self,
73        batch: usize,
74        b: &mut NaMatrix<T, Dyn, C2, S2>,
75    ) -> bool {
76        let ncols = self.logical_ncols();
77        let block = self.a.data.columns(batch * ncols, ncols);
78        for &(i, p) in &self.piv[batch] {
79            b.swap_rows(i, p);
80        }
81        let _ = block.solve_lower_triangular_with_diag_mut(b, T::one());
82        block.solve_upper_triangular_mut(b)
83    }
84
85    /// Solve `block(batch)^T * x = b` in place: `U^T z = b`, `L^T w = z`, then undo the pivots.
86    fn solve_transpose_block<C2: Dim, S2: StorageMut<T, Dyn, C2>>(
87        &self,
88        batch: usize,
89        b: &mut NaMatrix<T, Dyn, C2, S2>,
90    ) -> bool {
91        let ncols = self.logical_ncols();
92        let block = self.a.data.columns(batch * ncols, ncols);
93        if !block.tr_solve_upper_triangular_mut(b) {
94            return false;
95        }
96        // L has a unit diagonal, which nalgebra's transposed solves can't assume
97        for c in 0..b.ncols() {
98            for i in (0..ncols.saturating_sub(1)).rev() {
99                let s = block.view_range(i + 1.., i).dot(&b.view_range(i + 1.., c));
100                b[(i, c)] -= s;
101            }
102        }
103        for &(i, p) in self.piv[batch].iter().rev() {
104            b.swap_rows(i, p);
105        }
106        true
107    }
108
109    fn solve(&self, state: &mut NalgebraVec<T>, transpose: bool) -> Result<(), LaError> {
110        state
111            .context
112            .assert_broadcastable_into(self.nbatch(), "lu_solve");
113        let nb = state.context.nbatch();
114        for batch in 0..nb {
115            let lu_batch = broadcast_batch(batch, self.nbatch(), nb);
116            let mut state_batch = state.data.column_mut(batch);
117            let ok = if transpose {
118                self.solve_transpose_block(lu_batch, &mut state_batch)
119            } else {
120                self.solve_block(lu_batch, &mut state_batch)
121            };
122            if !ok {
123                return Err(linear_solver_error!(LuSolveFailed));
124            }
125        }
126        Ok(())
127    }
128}
129
130/// The default nalgebra [LinearSolver], exported as [NalgebraLU](crate::NalgebraLU). Factorizes
131/// its matrix entirely in place (no per-call matrix clone + fresh permutation sequence, both of
132/// which `nalgebra::linalg::LU::new` allocates every time).
133pub struct ReusableLU<T>
134where
135    T: NalgebraScalar,
136{
137    lu: Option<BatchLu<T>>,
138}
139
140impl<T: NalgebraScalar> Default for ReusableLU<T> {
141    fn default() -> Self {
142        Self { lu: None }
143    }
144}
145
146impl<T: NalgebraScalar> LinearSolver<NalgebraMat<T>> for ReusableLU<T> {
147    fn solve_in_place(&self, state: &mut NalgebraVec<T>) -> Result<(), LaError> {
148        let Some(lu) = self.lu.as_ref() else {
149            return Err(linear_solver_error!(LuNotInitialized));
150        };
151        lu.solve(state, false)
152    }
153
154    fn solve_transpose_in_place(&self, state: &mut NalgebraVec<T>) -> Result<(), LaError> {
155        let Some(lu) = self.lu.as_ref() else {
156            return Err(linear_solver_error!(LuNotInitialized));
157        };
158        lu.solve(state, true)
159    }
160
161    fn set_linearisation<
162        C: LinearOp<T = T, V = NalgebraVec<T>, M = NalgebraMat<T>, C = NalgebraContext>,
163    >(
164        &mut self,
165        op: &C,
166    ) {
167        let lu = self.lu.as_mut().expect("Matrix not set");
168        op.matrix_inplace(&mut lu.a);
169        lu.factor();
170    }
171
172    fn set_sparsity<
173        C: LinearOp<T = T, V = NalgebraVec<T>, M = NalgebraMat<T>, C = NalgebraContext>,
174    >(
175        &mut self,
176        op: &C,
177    ) {
178        let ncols = op.ncols();
179        let nrows = op.nrows();
180        let a = C::M::new_from_sparsity(nrows, ncols, op.sparsity(), *op.context());
181        self.lu = Some(BatchLu::new(a));
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    crate::linear_solver::generate_lu_tests!(
190        f64,
191        NalgebraMat<f64>,
192        ReusableLU<f64>,
193        NalgebraContext::with_nbatch(2)
194    );
195    crate::linear_solver::generate_lu_tests!(
196        f32,
197        NalgebraMat<f32>,
198        ReusableLU<f32>,
199        NalgebraContext::with_nbatch(2)
200    );
201
202    /// A square matrix stored as a [NalgebraMat], built by direct indexing.
203    fn make_mat(n: usize, data: &[f64]) -> NalgebraMat<f64> {
204        let mut m: NalgebraMat<f64> = Matrix::new_from_sparsity(n, n, None, Default::default());
205        for i in 0..n {
206            for j in 0..n {
207                m[(i, j)] = data[i * n + j];
208            }
209        }
210        m
211    }
212
213    /// `ReusableLU` should agree with `nalgebra::linalg::LU` on the same matrices, including
214    /// across repeated `factor` calls that exercise the in-place refactor path, and including
215    /// cases that require row pivoting (first case has a zero (0,0) entry).
216    #[test]
217    fn matches_nalgebra_lu() {
218        let cases: Vec<(usize, Vec<f64>)> = vec![
219            (1, vec![2.0]),
220            (2, vec![0.0, 1.0, 1.0, 1.0]),
221            (3, vec![2.0, 1.0, 1.0, 4.0, 3.0, 3.0, 8.0, 7.0, 9.0]),
222            (
223                5,
224                vec![
225                    2.0, 0.0, 1.0, 0.0, 0.0, 1.0, 3.0, 0.0, 0.0, 1.0, 0.0, 1.0, 4.0, 1.0, 0.0, 0.0,
226                    0.0, 2.0, 5.0, 1.0, 1.0, 0.0, 0.0, 1.0, 6.0,
227                ],
228            ),
229        ];
230        for (n, data) in cases {
231            let nm = make_mat(n, &data);
232            let b = nalgebra::DMatrix::<f64>::from_fn(n, 1, |i, _| (i + 1) as f64);
233
234            let mut lu = BatchLu::new(nm.clone());
235            lu.factor();
236            let mut got = b.clone();
237            assert!(lu.solve_block(0, &mut got));
238
239            let m = nalgebra::DMatrix::from_row_slice(n, n, &data);
240            let expected_lu = m.lu();
241            let mut expected = b.clone();
242            assert!(expected_lu.solve_mut(&mut expected));
243
244            for i in 0..n {
245                assert!(
246                    (got[(i, 0)] - expected[(i, 0)]).abs() < 1e-9,
247                    "n={n} row={i}: got {} expected {}",
248                    got[(i, 0)],
249                    expected[(i, 0)]
250                );
251            }
252
253            // refactoring in place (buffer reuse path) should give the same answer again --
254            // `a` must be repopulated first, mirroring what `set_linearisation` does via
255            // `matrix_inplace` before each `factor()` call (factoring an already-factored
256            // matrix again is not a supported operation).
257            lu.a = make_mat(n, &data);
258            lu.factor();
259            let mut got2 = b.clone();
260            assert!(lu.solve_block(0, &mut got2));
261            for i in 0..n {
262                assert!((got2[(i, 0)] - expected[(i, 0)]).abs() < 1e-9);
263            }
264        }
265    }
266
267    #[test]
268    fn singular_matrix_fails_like_nalgebra_lu() {
269        let nm = make_mat(2, &[1.0, 2.0, 2.0, 4.0]);
270        let mut lu = BatchLu::new(nm);
271        lu.factor();
272        let mut b = nalgebra::DMatrix::<f64>::from_row_slice(2, 1, &[1.0, 2.0]);
273        assert!(!lu.solve_block(0, &mut b));
274    }
275}