Files
rustytorch/crates/specialized/rtx-cfd/src/solvers/incompressible/sparse_bicgstab.rs
T
Omar SobhandClaude Fable 5.1 52da75a3a9
CI / Distributed Training Tests (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
rtx-cfd: curvilinear collocated PISO on a structured patch (overset A-P0, WIP) — PatchMesh (right-handed s,n; periodic seam with shift; face metrics), patch generators (TFI, skewed annulus, sheared/varying-skew channels), CSR + Jacobi-BiCGSTAB, the Zang–Street–Koseff incremental step with the node-based 9-point L_f, LSQ gradients, explicit and line-implicit-n predictors, adjustPhi; tests: mesh metrics (5 green), operators exact on linear fields incl. the seam (green), sparse (2 green), MMS ladder (Cartesian 16/32: 1.37–1.39x the staggered error, order 0.83; n=64 stalls at a |du/dt| floor 2e-4 — open, tolerance-scaling hypothesis), annulus/Poiseuille not yet run
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
2026-09-04 05:00:08 -07:00

316 lines
9.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A small CSR matrix and a Jacobi-preconditioned BiCGSTAB for the
//! curvilinear patch's pressure equation (`docs/overset_metal_campaign.md`
//! §5.3): the non-orthogonal operator is not symmetric, the patch is a few
//! thousand cells, and `solve_multigrid_pcg` is hard-wired to the
//! five-point Cartesian stencil.
/// Compressed sparse rows.
#[derive(Debug, Clone)]
pub struct CsrMatrix {
n: usize,
row_ptr: Vec<usize>,
col: Vec<usize>,
val: Vec<f64>,
}
impl CsrMatrix {
/// Build from `(row, col, value)` triplets; duplicates accumulate,
/// columns are sorted within each row.
pub fn from_triplets(n: usize, triplets: &[(usize, usize, f64)]) -> Self {
let mut rows: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
for &(r, c, v) in triplets {
rows[r].push((c, v));
}
let mut row_ptr = Vec::with_capacity(n + 1);
let mut col = Vec::with_capacity(triplets.len());
let mut val = Vec::with_capacity(triplets.len());
row_ptr.push(0);
for row in rows.iter_mut() {
row.sort_by_key(|e| e.0);
let mut last: Option<usize> = None;
for &(c, v) in row.iter() {
if last == Some(c) {
*val.last_mut().expect("entry") += v;
} else {
col.push(c);
val.push(v);
last = Some(c);
}
}
row_ptr.push(col.len());
}
Self {
n,
row_ptr,
col,
val,
}
}
/// Dimension.
pub fn n(&self) -> usize {
self.n
}
/// Stored entries.
pub fn nnz(&self) -> usize {
self.val.len()
}
/// `y = A x`.
pub fn matvec(&self, x: &[f64], y: &mut [f64]) {
for r in 0..self.n {
let mut acc = 0.0;
for k in self.row_ptr[r]..self.row_ptr[r + 1] {
acc += self.val[k] * x[self.col[k]];
}
y[r] = acc;
}
}
/// The diagonal (zero where absent).
pub fn diagonal(&self) -> Vec<f64> {
let mut d = vec![0.0; self.n];
for r in 0..self.n {
for k in self.row_ptr[r]..self.row_ptr[r + 1] {
if self.col[k] == r {
d[r] = self.val[k];
}
}
}
d
}
/// Replace row `r` by the identity row (`x_r = b_r`): the anchor of a
/// pure-Neumann problem.
pub fn set_row_identity(&mut self, r: usize) {
let mut has_diag = false;
for k in self.row_ptr[r]..self.row_ptr[r + 1] {
self.val[k] = if self.col[k] == r {
has_diag = true;
1.0
} else {
0.0
};
}
assert!(has_diag, "row {r} has no diagonal entry to anchor");
}
/// Row `r` as `(columns, values)`.
pub fn row(&self, r: usize) -> (&[usize], &[f64]) {
let (a, b) = (self.row_ptr[r], self.row_ptr[r + 1]);
(&self.col[a..b], &self.val[a..b])
}
}
/// Outcome of a BiCGSTAB solve.
#[derive(Debug, Clone, Copy)]
#[must_use]
pub struct BicgstabResult {
/// Iterations taken.
pub iterations: usize,
/// L1 norm of the true residual `b A x` at exit.
pub residual: f64,
/// Whether the residual reached the tolerance.
pub converged: bool,
}
/// Subtract the mean from `v` (the consistency projection for a singular
/// right-hand side).
pub fn project_mean(v: &mut [f64]) {
let mean = v.iter().sum::<f64>() / v.len() as f64;
for x in v.iter_mut() {
*x -= mean;
}
}
/// Jacobi-preconditioned BiCGSTAB (van der Vorst 1992) with an L1
/// true-residual stop; `x` is the initial guess and the result.
pub fn bicgstab_jacobi(
a: &CsrMatrix,
b: &[f64],
x: &mut [f64],
tolerance: f64,
max_iterations: usize,
) -> BicgstabResult {
let n = a.n();
let diag = a.diagonal();
let inv_diag: Vec<f64> = diag
.iter()
.map(|&d| if d != 0.0 { 1.0 / d } else { 1.0 })
.collect();
let l1 = |v: &[f64]| v.iter().map(|t| t.abs()).sum::<f64>();
let dot = |u: &[f64], v: &[f64]| u.iter().zip(v).map(|(p, q)| p * q).sum::<f64>();
let mut r = vec![0.0; n];
a.matvec(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
let mut res = l1(&r);
if res <= tolerance {
return BicgstabResult {
iterations: 0,
residual: res,
converged: true,
};
}
let r0 = r.clone();
let mut p = vec![0.0; n];
let mut v = vec![0.0; n];
let mut s = vec![0.0; n];
let mut t = vec![0.0; n];
let mut y = vec![0.0; n];
let mut z = vec![0.0; n];
let (mut rho_old, mut alpha, mut omega) = (1.0, 1.0, 1.0);
for it in 1..=max_iterations {
let rho = dot(&r0, &r);
if rho == 0.0 || !rho.is_finite() {
break;
}
let beta = (rho / rho_old) * (alpha / omega);
for i in 0..n {
p[i] = r[i] + beta * (p[i] - omega * v[i]);
}
for i in 0..n {
y[i] = inv_diag[i] * p[i];
}
a.matvec(&y, &mut v);
let r0v = dot(&r0, &v);
if r0v == 0.0 || !r0v.is_finite() {
break;
}
alpha = rho / r0v;
for i in 0..n {
s[i] = r[i] - alpha * v[i];
}
if l1(&s) <= tolerance {
for i in 0..n {
x[i] += alpha * y[i];
}
a.matvec(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
res = l1(&r);
return BicgstabResult {
iterations: it,
residual: res,
converged: res <= tolerance,
};
}
for i in 0..n {
z[i] = inv_diag[i] * s[i];
}
a.matvec(&z, &mut t);
let tt = dot(&t, &t);
omega = if tt > 0.0 { dot(&t, &s) / tt } else { 0.0 };
for i in 0..n {
x[i] += alpha * y[i] + omega * z[i];
r[i] = s[i] - omega * t[i];
}
rho_old = rho;
// The recurrence residual drifts from the true one; check the true
// residual whenever the recurrence claims convergence.
if l1(&r) <= tolerance {
a.matvec(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
res = l1(&r);
if res <= tolerance {
return BicgstabResult {
iterations: it,
residual: res,
converged: true,
};
}
}
if omega == 0.0 {
break;
}
}
a.matvec(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
res = l1(&r);
BicgstabResult {
iterations: max_iterations,
residual: res,
converged: res <= tolerance,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_small_nonsymmetric_system_is_solved_to_rounding() {
// Diagonally dominant, non-symmetric.
let dense = [
[4.0, -1.0, 0.0, 0.5, 0.0],
[-0.5, 5.0, -1.0, 0.0, 0.2],
[0.0, -1.5, 6.0, -1.0, 0.0],
[0.1, 0.0, -1.0, 4.0, -1.0],
[0.0, 0.3, 0.0, -0.5, 3.0],
];
let mut tri = Vec::new();
for (r, row) in dense.iter().enumerate() {
for (c, &v) in row.iter().enumerate() {
if v != 0.0 {
tri.push((r, c, v));
}
}
}
// Duplicate entry: must accumulate.
tri.push((0, 1, -0.5));
tri.push((0, 1, 0.5));
let a = CsrMatrix::from_triplets(5, &tri);
assert_eq!(a.nnz(), 17);
let x_true = [1.0, -2.0, 3.0, 0.5, -1.5];
let mut b = vec![0.0; 5];
a.matvec(&x_true, &mut b);
let mut x = vec![0.0; 5];
let out = bicgstab_jacobi(&a, &b, &mut x, 1e-13, 100);
assert!(out.converged, "{out:?}");
for i in 0..5 {
assert!(
(x[i] - x_true[i]).abs() < 1e-11,
"x[{i}] = {} vs {}",
x[i],
x_true[i]
);
}
}
#[test]
fn an_anchored_periodic_laplacian_recovers_a_periodic_field_up_to_a_constant() {
// 1-D periodic second difference (singular): anchor one row, project
// the mean out of the right-hand side.
let n = 64;
let h = 1.0 / n as f64;
let mut tri = Vec::new();
for i in 0..n {
tri.push((i, i, 2.0 / (h * h)));
tri.push((i, (i + 1) % n, -1.0 / (h * h)));
tri.push((i, (i + n - 1) % n, -1.0 / (h * h)));
}
let mut a = CsrMatrix::from_triplets(n, &tri);
let phi = |x: f64| (2.0 * std::f64::consts::PI * x).sin();
let exact: Vec<f64> = (0..n).map(|i| phi((i as f64 + 0.5) * h)).collect();
let mut b = vec![0.0; n];
a.matvec(&exact, &mut b);
b[3] += 1e-3; // an inconsistent perturbation the projection must remove
project_mean(&mut b);
a.set_row_identity(0);
b[0] = 0.0;
let mut x = vec![0.0; n];
// 1e-9 absolute: |A| ~ 1/h² = 4e3 and |x| ~ 1 put the rounding floor near 1e-10.
let out = bicgstab_jacobi(&a, &b, &mut x, 1e-9, 2000);
assert!(out.converged, "{out:?}");
let shift = exact[0] - x[0];
let worst = (0..n)
.map(|i| (x[i] + shift - exact[i]).abs())
.fold(0.0, f64::max);
assert!(worst < 2e-3, "worst {worst:.3e}"); // the 1e-3 perturbation's response bounds it
}
}