1use core::fmt::Debug;
73
74use dyn_stack::{MemStack, StackReq};
75use faer::matrix_free::{BiLinOp, BiPrecond, LinOp, Precond};
76use faer::{Conj, MatMut, MatRef, Par};
77use faer_traits::{ComplexField, Index};
78
79pub mod apply;
80pub mod numeric;
81
82pub use numeric::Ilutp;
83
84#[derive(Debug, Clone, Copy, PartialEq)]
86pub enum FillControl {
87 PerRow(usize),
90 Factor(f64),
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum RowNorm {
98 One,
100 Two,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct IlutpParams {
120 pub drop_tol: f64,
123 pub fill: FillControl,
125 pub pivot_tol: f64,
127 pub norm: RowNorm,
129}
130
131impl Default for IlutpParams {
132 fn default() -> Self {
133 Self {
134 drop_tol: 1e-3,
135 fill: FillControl::Factor(5.0),
136 pivot_tol: 0.1,
137 norm: RowNorm::Two,
138 }
139 }
140}
141
142impl IlutpParams {
143 pub(crate) fn validate(&self) -> Result<(), IlutpError> {
144 if !self.drop_tol.is_finite() || self.drop_tol < 0.0 {
145 return Err(IlutpError::InvalidDropTol);
146 }
147 if !self.pivot_tol.is_finite() || self.pivot_tol < 0.0 || self.pivot_tol > 1.0 {
148 return Err(IlutpError::InvalidPivotTol);
149 }
150 if let FillControl::Factor(f) = self.fill
151 && (!f.is_finite() || f <= 0.0)
152 {
153 return Err(IlutpError::InvalidFillControl);
154 }
155 Ok(())
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum IlutpError {
162 NonSquareMatrix { nrows: usize, ncols: usize },
164 ZeroPivot { row: usize },
167 InvalidDropTol,
169 InvalidPivotTol,
171 InvalidFillControl,
173}
174
175impl core::fmt::Display for IlutpError {
176 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
177 match self {
178 Self::NonSquareMatrix { nrows, ncols } => {
179 write!(f, "matrix must be square but is {nrows}x{ncols}")
180 }
181 Self::ZeroPivot { row } => write!(f, "encountered a zero pivot at row {row}"),
182 Self::InvalidDropTol => f.write_str("drop_tol must be finite and non-negative"),
183 Self::InvalidPivotTol => f.write_str("pivot_tol must be finite and within [0, 1]"),
184 Self::InvalidFillControl => f.write_str("fill factor must be finite and positive"),
185 }
186 }
187}
188
189impl core::error::Error for IlutpError {}
190
191impl<I, T> LinOp<T> for Ilutp<I, T>
192where
193 I: Index,
194 T: ComplexField + Debug + Sync,
195{
196 fn apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
197 StackReq::new::<T>(self.dim())
198 }
199
200 fn nrows(&self) -> usize {
201 self.dim()
202 }
203
204 fn ncols(&self) -> usize {
205 self.dim()
206 }
207
208 fn apply(&self, mut out: MatMut<'_, T>, rhs: MatRef<'_, T>, par: Par, stack: &mut MemStack) {
209 assert_eq!(
210 out.nrows(),
211 self.dim(),
212 "out row count must match dimension"
213 );
214 assert_eq!(
215 rhs.nrows(),
216 self.dim(),
217 "rhs row count must match dimension"
218 );
219 assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
220 out.copy_from(rhs);
221 apply::solve_in_place(self, Conj::No, out, par, stack);
222 }
223
224 fn conj_apply(
225 &self,
226 mut out: MatMut<'_, T>,
227 rhs: MatRef<'_, T>,
228 par: Par,
229 stack: &mut MemStack,
230 ) {
231 assert_eq!(
232 out.nrows(),
233 self.dim(),
234 "out row count must match dimension"
235 );
236 assert_eq!(
237 rhs.nrows(),
238 self.dim(),
239 "rhs row count must match dimension"
240 );
241 assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
242 out.copy_from(rhs);
243 apply::solve_in_place(self, Conj::Yes, out, par, stack);
244 }
245}
246
247impl<I, T> Precond<T> for Ilutp<I, T>
248where
249 I: Index,
250 T: ComplexField + Debug + Sync,
251{
252 fn apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
253 StackReq::new::<T>(self.dim())
254 }
255
256 fn apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
257 apply::solve_in_place(self, Conj::No, rhs, par, stack);
258 }
259
260 fn conj_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
261 apply::solve_in_place(self, Conj::Yes, rhs, par, stack);
262 }
263}
264
265impl<I, T> BiLinOp<T> for Ilutp<I, T>
266where
267 I: Index,
268 T: ComplexField + Debug + Sync,
269{
270 fn transpose_apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
271 StackReq::new::<T>(self.dim())
272 }
273
274 fn transpose_apply(
275 &self,
276 mut out: MatMut<'_, T>,
277 rhs: MatRef<'_, T>,
278 par: Par,
279 stack: &mut MemStack,
280 ) {
281 assert_eq!(
282 out.nrows(),
283 self.dim(),
284 "out row count must match dimension"
285 );
286 assert_eq!(
287 rhs.nrows(),
288 self.dim(),
289 "rhs row count must match dimension"
290 );
291 assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
292 out.copy_from(rhs);
293 apply::solve_transpose_in_place(self, Conj::No, out, par, stack);
294 }
295
296 fn adjoint_apply(
297 &self,
298 mut out: MatMut<'_, T>,
299 rhs: MatRef<'_, T>,
300 par: Par,
301 stack: &mut MemStack,
302 ) {
303 assert_eq!(
304 out.nrows(),
305 self.dim(),
306 "out row count must match dimension"
307 );
308 assert_eq!(
309 rhs.nrows(),
310 self.dim(),
311 "rhs row count must match dimension"
312 );
313 assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
314 out.copy_from(rhs);
315 apply::solve_transpose_in_place(self, Conj::Yes, out, par, stack);
316 }
317}
318
319impl<I, T> BiPrecond<T> for Ilutp<I, T>
320where
321 I: Index,
322 T: ComplexField + Debug + Sync,
323{
324 fn transpose_apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
325 StackReq::new::<T>(self.dim())
326 }
327
328 fn transpose_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
329 apply::solve_transpose_in_place(self, Conj::No, rhs, par, stack);
330 }
331
332 fn adjoint_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
333 apply::solve_transpose_in_place(self, Conj::Yes, rhs, par, stack);
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use core::mem::MaybeUninit;
341 use faer::sparse::{SparseColMat, SparseColMatRef, Triplet};
342 use faer::{Mat, MatRef, mat};
343
344 fn with_stack(req: StackReq, f: impl FnOnce(&mut MemStack)) {
345 let nbytes = req.unaligned_bytes_required().max(1);
346 let mut buf = vec![MaybeUninit::<u8>::uninit(); nbytes].into_boxed_slice();
347 f(MemStack::new(&mut buf));
348 }
349
350 fn assert_close(lhs: MatRef<'_, f64>, rhs: MatRef<'_, f64>, tol: f64) {
351 assert_eq!(lhs.nrows(), rhs.nrows());
352 assert_eq!(lhs.ncols(), rhs.ncols());
353 for j in 0..lhs.ncols() {
354 for i in 0..lhs.nrows() {
355 let diff = (*lhs.get(i, j) - *rhs.get(i, j)).abs();
356 assert!(
357 diff <= tol,
358 "mismatch at ({i}, {j}): lhs={}, rhs={}, diff={diff}",
359 *lhs.get(i, j),
360 *rhs.get(i, j),
361 );
362 }
363 }
364 }
365
366 fn to_dense(a: &SparseColMat<usize, f64>) -> Mat<f64> {
367 let n = a.nrows();
368 let mut out = Mat::<f64>::zeros(n, a.ncols());
369 let a_ref = a.as_ref();
370 for j in 0..a.ncols() {
371 let rows = a_ref.symbolic().row_idx_of_col_raw(j);
372 let vals = a_ref.val_of_col(j);
373 for (r, v) in rows.iter().zip(vals.iter()) {
374 *out.as_mut().get_mut(*r, j) = *v;
375 }
376 }
377 out
378 }
379
380 fn sparse_view_to_dense(a: SparseColMatRef<'_, usize, f64>) -> Mat<f64> {
381 let mut dense = Mat::<f64>::zeros(a.nrows(), a.ncols());
382 for j in 0..a.ncols() {
383 let rows = a.symbolic().row_idx_of_col_raw(j);
384 let vals = a.val_of_col(j);
385 for (r, v) in rows.iter().zip(vals.iter()) {
386 *dense.as_mut().get_mut(*r, j) = *v;
387 }
388 }
389 dense
390 }
391
392 fn tridiagonal(n: usize, diag: f64, sub: f64, sup: f64) -> SparseColMat<usize, f64> {
394 let mut triplets = Vec::new();
395 for i in 0..n {
396 triplets.push(Triplet::new(i, i, diag));
397 if i > 0 {
398 triplets.push(Triplet::new(i, i - 1, sub));
399 triplets.push(Triplet::new(i - 1, i, sup));
400 }
401 }
402 SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
403 }
404
405 fn advection_diffusion_2d(grid: usize, beta: f64) -> SparseColMat<usize, f64> {
408 let n = grid * grid;
409 let mut triplets = Vec::new();
410 for gy in 0..grid {
411 for gx in 0..grid {
412 let idx = gy * grid + gx;
413 triplets.push(Triplet::new(idx, idx, 4.0));
414 if gx > 0 {
415 triplets.push(Triplet::new(idx, idx - 1, -1.0 - beta));
416 }
417 if gx + 1 < grid {
418 triplets.push(Triplet::new(idx, idx + 1, -1.0 + beta));
419 }
420 if gy > 0 {
421 triplets.push(Triplet::new(idx, idx - grid, -1.0));
422 }
423 if gy + 1 < grid {
424 triplets.push(Triplet::new(idx, idx + grid, -1.0));
425 }
426 }
427 }
428 SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
429 }
430
431 fn apply_inplace(pc: &Ilutp<usize, f64>, rhs: &mut Mat<f64>) {
432 with_stack(pc.apply_in_place_scratch(rhs.ncols(), Par::Seq), |stack| {
433 pc.apply_in_place(rhs.as_mut(), Par::Seq, stack);
434 });
435 }
436
437 fn exact_params(n: usize, pivot_tol: f64) -> IlutpParams {
439 IlutpParams {
440 drop_tol: 0.0,
441 fill: FillControl::PerRow(n),
442 pivot_tol,
443 norm: RowNorm::Two,
444 }
445 }
446
447 #[test]
448 fn exact_keep_inverts_system() {
449 let a = tridiagonal(8, 4.0, -2.0, -1.0);
451 let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(8, 0.0)).unwrap();
452 assert!(!pc.is_permuted());
453
454 let a_dense = to_dense(&a);
455 let x_true = mat![
456 [1.0],
457 [-2.0],
458 [3.0],
459 [-1.0],
460 [0.5],
461 [2.0],
462 [-3.0],
463 [1.5_f64]
464 ];
465 let mut rhs = (&a_dense * &x_true).to_owned();
466 apply_inplace(&pc, &mut rhs);
467 assert_close(rhs.as_ref(), x_true.as_ref(), 1e-10);
468 }
469
470 #[test]
471 fn exact_keep_with_pivoting_still_inverts() {
472 let a = tridiagonal(8, 4.0, -2.0, -1.0);
475 let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(8, 0.5)).unwrap();
476
477 let a_dense = to_dense(&a);
478 let x_true = mat![
479 [1.0],
480 [-2.0],
481 [3.0],
482 [-1.0],
483 [0.5],
484 [2.0],
485 [-3.0],
486 [1.5_f64]
487 ];
488 let mut rhs = (&a_dense * &x_true).to_owned();
489 apply_inplace(&pc, &mut rhs);
490 assert_close(rhs.as_ref(), x_true.as_ref(), 1e-10);
491 }
492
493 #[test]
494 fn reconstruction_matches_permuted_a() {
495 let a = advection_diffusion_2d(4, 0.3);
497 let n = a.nrows();
498 let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(n, 0.0)).unwrap();
499 assert!(!pc.is_permuted());
500
501 let l = sparse_view_to_dense(pc.l_view());
502 let u = sparse_view_to_dense(pc.u_view());
503 let lu = &l * &u;
504 let a_dense = to_dense(&a);
505 assert_close(lu.as_ref(), a_dense.as_ref(), 1e-9);
506 }
507
508 #[test]
509 fn reconstruction_matches_permuted_a_with_pivoting() {
510 let a = advection_diffusion_2d(4, 0.4);
512 let n = a.nrows();
513 let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(n, 0.5)).unwrap();
514
515 let l = sparse_view_to_dense(pc.l_view());
516 let u = sparse_view_to_dense(pc.u_view());
517 let lu = &l * &u;
518
519 let a_dense = to_dense(&a);
520 let perm = pc.perm();
521 let mut ap = Mat::<f64>::zeros(n, n);
522 for (k, &pk) in perm.iter().enumerate() {
523 for r in 0..n {
524 *ap.as_mut().get_mut(r, k) = *a_dense.as_ref().get(r, pk);
525 }
526 }
527 assert_close(lu.as_ref(), ap.as_ref(), 1e-9);
528 }
529
530 #[test]
531 fn pivot_tol_zero_is_pure_ilut() {
532 let a = mat_to_sparse(&[&[1e-8, 1.0, 0.0], &[1.0, 1.0, 1.0], &[0.0, 1.0, 1.0]]);
534 let params = IlutpParams {
535 pivot_tol: 0.0,
536 ..exact_params(3, 0.0)
537 };
538 let pc = Ilutp::try_new_with_params(a.as_ref(), params).unwrap();
539 assert!(!pc.is_permuted());
540 }
541
542 #[test]
543 fn pivoting_triggers_on_tiny_diagonal() {
544 let a = mat_to_sparse(&[&[1e-8, 1.0, 0.0], &[1.0, 1.0, 1.0], &[0.0, 1.0, 1.0]]);
545 let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(3, 0.5)).unwrap();
546 assert!(pc.is_permuted(), "tiny diagonal should force a pivot");
547
548 let u = sparse_view_to_dense(pc.u_view());
550 assert!(
551 u.as_ref().get(0, 0).abs() > 1e-3,
552 "pivoting should avoid the tiny pivot, got {}",
553 u.as_ref().get(0, 0)
554 );
555 }
556
557 #[test]
558 fn reduces_residual_on_nonsymmetric_problem() {
559 let a = advection_diffusion_2d(8, 0.5);
560 let n = a.nrows();
561 let pc = Ilutp::try_new(a.as_ref()).unwrap();
562 let a_dense = to_dense(&a);
563
564 let b = Mat::<f64>::from_fn(n, 1, |i, _| (i % 7) as f64 - 3.0);
565 let mut x = b.clone();
566 apply_inplace(&pc, &mut x);
567
568 let residual = &a_dense * &x - &b;
569 let b_norm: f64 = b.as_ref().col(0).iter().map(|v| v * v).sum::<f64>().sqrt();
570 let r_norm: f64 = residual
571 .as_ref()
572 .col(0)
573 .iter()
574 .map(|v| v * v)
575 .sum::<f64>()
576 .sqrt();
577 assert!(
578 r_norm / b_norm < 0.5,
579 "ILUTP residual ratio {r_norm}/{b_norm} too large"
580 );
581 }
582
583 #[test]
584 fn transpose_apply_inverts_transposed_system() {
585 let a = tridiagonal(6, 4.0, -2.0, -1.0);
586 let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(6, 0.0)).unwrap();
587 let a_dense = to_dense(&a);
588
589 let x_true = mat![[1.0], [-2.0], [3.0], [-1.0], [0.5], [2.0_f64]];
590 let rhs = a_dense.transpose() * &x_true;
591
592 let mut out = rhs.clone();
593 with_stack(pc.transpose_apply_in_place_scratch(1, Par::Seq), |stack| {
594 pc.transpose_apply_in_place(out.as_mut(), Par::Seq, stack);
595 });
596 assert_close(out.as_ref(), x_true.as_ref(), 1e-10);
597 }
598
599 #[test]
600 fn out_of_place_matches_in_place() {
601 let a = advection_diffusion_2d(5, 0.3);
602 let n = a.nrows();
603 let pc = Ilutp::try_new(a.as_ref()).unwrap();
604
605 let rhs = Mat::<f64>::from_fn(n, 2, |i, j| ((i + 3 * j) % 11) as f64 - 4.0);
606
607 let mut out = Mat::<f64>::zeros(n, 2);
608 with_stack(pc.apply_scratch(2, Par::Seq), |stack| {
609 pc.apply(out.as_mut(), rhs.as_ref(), Par::Seq, stack);
610 });
611
612 let mut inplace = rhs.clone();
613 apply_inplace(&pc, &mut inplace);
614
615 assert_close(out.as_ref(), inplace.as_ref(), 1e-12);
616 }
617
618 #[test]
619 fn refactorize_matches_fresh_construction() {
620 let a1 = tridiagonal(7, 4.0, -2.0, -1.0);
622 let a2 = tridiagonal(7, 5.0, -1.0, -2.0);
623 let params = IlutpParams {
624 pivot_tol: 0.0,
625 ..IlutpParams::default()
626 };
627
628 let fresh = Ilutp::try_new_with_params(a2.as_ref(), params).unwrap();
629
630 let mut reused = Ilutp::try_new_with_params(a1.as_ref(), params).unwrap();
631 reused.refactorize(a2.as_ref()).unwrap();
632
633 let lf = sparse_view_to_dense(fresh.l_view());
634 let lr = sparse_view_to_dense(reused.l_view());
635 let uf = sparse_view_to_dense(fresh.u_view());
636 let ur = sparse_view_to_dense(reused.u_view());
637 assert_close(lf.as_ref(), lr.as_ref(), 1e-14);
638 assert_close(uf.as_ref(), ur.as_ref(), 1e-14);
639 }
640
641 #[test]
642 fn rejects_non_square() {
643 let mut triplets = Vec::new();
644 for i in 0..3 {
645 triplets.push(Triplet::new(i, i, 1.0));
646 }
647 let a = SparseColMat::<usize, f64>::try_new_from_triplets(3, 4, &triplets).unwrap();
648 let err = Ilutp::try_new(a.as_ref()).unwrap_err();
649 assert_eq!(err, IlutpError::NonSquareMatrix { nrows: 3, ncols: 4 });
650 }
651
652 #[test]
653 fn rejects_zero_pivot_without_pivoting() {
654 let a = mat_to_sparse(&[&[0.0, 0.0, 0.0], &[1.0, 1.0, 0.0], &[0.0, 1.0, 1.0]]);
657 let params = IlutpParams {
658 pivot_tol: 0.0,
659 ..exact_params(3, 0.0)
660 };
661 let err = Ilutp::try_new_with_params(a.as_ref(), params).unwrap_err();
662 assert_eq!(err, IlutpError::ZeroPivot { row: 0 });
663 }
664
665 #[test]
666 fn rejects_invalid_params() {
667 let a = tridiagonal(3, 4.0, -1.0, -1.0);
668 let bad_drop = IlutpParams {
669 drop_tol: -1.0,
670 ..Default::default()
671 };
672 assert_eq!(
673 Ilutp::try_new_with_params(a.as_ref(), bad_drop).unwrap_err(),
674 IlutpError::InvalidDropTol
675 );
676 let bad_pivot = IlutpParams {
677 pivot_tol: 1.5,
678 ..Default::default()
679 };
680 assert_eq!(
681 Ilutp::try_new_with_params(a.as_ref(), bad_pivot).unwrap_err(),
682 IlutpError::InvalidPivotTol
683 );
684 let bad_fill = IlutpParams {
685 fill: FillControl::Factor(0.0),
686 ..Default::default()
687 };
688 assert_eq!(
689 Ilutp::try_new_with_params(a.as_ref(), bad_fill).unwrap_err(),
690 IlutpError::InvalidFillControl
691 );
692 }
693
694 fn mat_to_sparse(rows: &[&[f64]]) -> SparseColMat<usize, f64> {
696 let n = rows.len();
697 let mut triplets = Vec::new();
698 for (i, row) in rows.iter().enumerate() {
699 for (j, &v) in row.iter().enumerate() {
700 if v != 0.0 {
701 triplets.push(Triplet::new(i, j, v));
702 }
703 }
704 }
705 SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
706 }
707}