use faer::Mat;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PcgParams {
pub max_iterations: usize,
pub tolerance: f64,
pub q_tolerance: f64,
}
pub const DEFAULT_ETA: f64 = 1e-2;
impl Default for PcgParams {
fn default() -> Self {
Self {
max_iterations: 200,
tolerance: 1e-6,
q_tolerance: DEFAULT_ETA,
}
}
}
impl PcgParams {
pub fn new(max_iterations: usize, tolerance: f64) -> Self {
Self {
max_iterations,
tolerance,
..Self::default()
}
}
pub fn with_q_tolerance(mut self, q_tolerance: f64) -> Self {
self.q_tolerance = q_tolerance;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PcgTermination {
Residual,
QuadraticModel,
MaxIterations,
Breakdown,
}
#[derive(Debug, Clone)]
pub struct PcgResult {
pub x: Mat<f64>,
pub iterations: usize,
pub final_residual: f64,
pub termination: PcgTermination,
}
pub fn pcg(
b: &Mat<f64>,
params: &PcgParams,
mut apply_operator: impl FnMut(&Mat<f64>, &mut Mat<f64>),
mut apply_preconditioner: impl FnMut(&Mat<f64>, &mut Mat<f64>),
) -> PcgResult {
let n = b.nrows();
let mut x = Mat::<f64>::zeros(n, 1);
let mut r = b.clone();
let tol = params.tolerance * r.norm_l2().max(1.0);
let mut z = Mat::<f64>::zeros(n, 1);
apply_preconditioner(&r, &mut z);
let mut p = z.clone();
let mut rz_old: f64 = (r.transpose() * &z)[(0, 0)];
let mut ap = Mat::<f64>::zeros(n, 1);
let mut iterations = 0usize;
let mut termination = PcgTermination::MaxIterations;
let mut q_old = 0.0f64;
for iter in 0..params.max_iterations {
iterations = iter + 1;
ap.fill(0.0);
apply_operator(&p, &mut ap);
let p_ap: f64 = (p.transpose() * &ap)[(0, 0)];
if p_ap.abs() < 1e-20 {
iterations = iter;
termination = PcgTermination::Breakdown;
break;
}
let alpha = rz_old / p_ap;
faer::zip!(&mut x, &p).for_each(|faer::unzip!(x, p)| *x += alpha * p);
faer::zip!(&mut r, &ap).for_each(|faer::unzip!(r, ap)| *r -= alpha * ap);
if r.norm_l2() < tol {
termination = PcgTermination::Residual;
break;
}
if params.q_tolerance > 0.0 {
let q_new = -((x.transpose() * (&r + b))[(0, 0)]);
if q_new.abs() > f64::MIN_POSITIVE {
let zeta = iterations as f64 * (q_new - q_old) / q_new;
if zeta < params.q_tolerance {
termination = PcgTermination::QuadraticModel;
break;
}
}
q_old = q_new;
}
apply_preconditioner(&r, &mut z);
let rz_new: f64 = (r.transpose() * &z)[(0, 0)];
if rz_old.abs() < 1e-30 {
termination = PcgTermination::Breakdown;
break;
}
let beta = rz_new / rz_old;
faer::zip!(&mut p, &z).for_each(|faer::unzip!(p, z)| *p = *z + beta * *p);
rz_old = rz_new;
}
PcgResult {
final_residual: r.norm_l2(),
x,
iterations,
termination,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pcg_solves_small_spd_system_with_identity_preconditioner() {
let a = [[4.0, 1.0], [1.0, 3.0]];
let b = Mat::from_fn(2, 1, |i, _| [1.0, 2.0][i]);
let result = pcg(
&b,
&PcgParams::new(50, 1e-12),
|p, ap| {
for i in 0..2 {
let mut acc = 0.0;
for j in 0..2 {
acc += a[i][j] * p[(j, 0)];
}
ap[(i, 0)] += acc;
}
},
|r, z| {
z[(0, 0)] = r[(0, 0)];
z[(1, 0)] = r[(1, 0)];
},
);
assert!((result.x[(0, 0)] - 1.0 / 11.0).abs() < 1e-8);
assert!((result.x[(1, 0)] - 7.0 / 11.0).abs() < 1e-8);
assert!(
result.iterations <= 2,
"SPD 2x2 converges in at most 2 CG steps"
);
}
#[test]
fn pcg_jacobi_preconditioner_matches_identity_solution() {
let a = [[10.0, 1.0], [1.0, 8.0]];
let b = Mat::from_fn(2, 1, |i, _| [3.0, 5.0][i]);
let inv_diag = [1.0 / a[0][0], 1.0 / a[1][1]];
let result = pcg(
&b,
&PcgParams::new(50, 1e-12),
|p, ap| {
for i in 0..2 {
let mut acc = 0.0;
for j in 0..2 {
acc += a[i][j] * p[(j, 0)];
}
ap[(i, 0)] += acc;
}
},
|r, z| {
z[(0, 0)] = inv_diag[0] * r[(0, 0)];
z[(1, 0)] = inv_diag[1] * r[(1, 0)];
},
);
let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
let want0 = (b[(0, 0)] * a[1][1] - a[0][1] * b[(1, 0)]) / det;
let want1 = (a[0][0] * b[(1, 0)] - b[(0, 0)] * a[1][0]) / det;
assert!((result.x[(0, 0)] - want0).abs() < 1e-8);
assert!((result.x[(1, 0)] - want1).abs() < 1e-8);
}
#[test]
fn forcing_sequence_stops_early_on_a_stagnating_residual() {
let n = 60;
let diag: Vec<f64> = (0..n)
.map(|i| 1.0 + (i as f64 / (n - 1) as f64) * 1e8)
.collect();
let b = Mat::from_fn(n, 1, |i, _| ((i % 7) as f64) - 3.0);
let op = |p: &Mat<f64>, ap: &mut Mat<f64>| {
for i in 0..n {
ap[(i, 0)] += diag[i] * p[(i, 0)];
}
};
let identity = |r: &Mat<f64>, z: &mut Mat<f64>| {
for i in 0..n {
z[(i, 0)] = r[(i, 0)];
}
};
let tight = pcg(
&b,
&PcgParams::new(200, 1e-12).with_q_tolerance(0.0),
op,
identity,
);
let forced = pcg(&b, &PcgParams::new(200, 1e-12), op, identity);
assert_eq!(
forced.termination,
PcgTermination::QuadraticModel,
"the forcing sequence should be what stops this solve"
);
assert!(
forced.iterations < tight.iterations,
"forcing sequence must save iterations: {} vs {}",
forced.iterations,
tight.iterations
);
let phi = |x: &Mat<f64>| {
let mut acc = 0.0;
for i in 0..n {
acc += 0.5 * diag[i] * x[(i, 0)] * x[(i, 0)] - b[(i, 0)] * x[(i, 0)];
}
acc
};
assert!(phi(&forced.x) < 0.0, "truncated step must reduce the model");
}
#[test]
fn zero_q_tolerance_disables_the_forcing_sequence() {
let a = [[4.0, 1.0], [1.0, 3.0]];
let b = Mat::from_fn(2, 1, |i, _| [1.0, 2.0][i]);
let result = pcg(
&b,
&PcgParams::new(50, 1e-12).with_q_tolerance(0.0),
|p, ap| {
for i in 0..2 {
for j in 0..2 {
ap[(i, 0)] += a[i][j] * p[(j, 0)];
}
}
},
|r, z| {
z[(0, 0)] = r[(0, 0)];
z[(1, 0)] = r[(1, 0)];
},
);
assert_ne!(result.termination, PcgTermination::QuadraticModel);
assert!((result.x[(0, 0)] - 1.0 / 11.0).abs() < 1e-8);
assert!((result.x[(1, 0)] - 7.0 / 11.0).abs() < 1e-8);
}
#[test]
fn pcg_handles_zero_rhs() {
let b = Mat::<f64>::zeros(3, 1);
let result = pcg(
&b,
&PcgParams::default(),
|_p, _ap| {},
|r, z| {
z[(0, 0)] = r[(0, 0)];
z[(1, 0)] = r[(1, 0)];
z[(2, 0)] = r[(2, 0)];
},
);
for i in 0..3 {
assert!(result.x[(i, 0)].is_finite());
}
}
}