Files
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

143 lines
4.6 KiB
Go
Raw Permalink 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.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Nonsymmetric sparse solve. SpSolve's conjugate gradient leans on
// symmetry for its short recurrences; the convection terms that make
// systems nonsymmetric break that structure, and the fix is van der
// Vorst's BiCGSTAB: two nested three-term recurrences whose second
// half smooths the erratic convergence the plain biconjugate gradient
// shows. Every step costs two sparse products and two preconditioner
// applications, the same order as one CG step pair.
//
// The Jacobi preconditioner needs a nonzero diagonal, which for the
// systems this solver targets (shifted Laplacians with convection,
// discretised transport) is present. Breakdowns (the recurrences
// collapsing) are reported as errors with the step they happened at,
// never swallowed.
// SpSolveBiCGSTAB returns the vector x solving A·x = b for a general
// real square sparse A, by preconditioned BiCGSTAB. Unlike SpSolve it
// asks for no symmetry; like SpSolve the stopping rule is the
// relative residual ‖b − A·x‖₂ ≤ tol·‖b‖₂ (tol ≤ 0 means 1e-10),
// maxIter ≤ 0 means n steps, and an unconverged solve is an error
// naming the residual achieved. The default preconditioner is Jacobi
// scaling, which divides by the diagonal and refuses a zero or
// missing entry; passing an ILU(0) factorisation from NewSparseILU
// replaces it and usually cuts the step count on hard systems.
func SpSolveBiCGSTAB(a *core.SparseCOO, b *core.Array, tol float64, maxIter int, precond ...*SparseILU) (*core.Array, error) {
const name = "SpSolveBiCGSTAB"
n, err := checkSparseSquare(name, a, b)
if err != nil {
return nil, err
}
c, err := cooToCSR(a, name)
if err != nil {
return nil, err
}
ilu, diag, err := pickPreconditioner(name, c, precond)
if err != nil {
return nil, err
}
if tol <= 0 {
tol = spSolveTol
}
if maxIter <= 0 {
maxIter = n
}
r := vectorF64(b, n)
bNorm := norm2F64(r)
if bNorm == 0 {
return core.Zeros(core.Float, n)
}
rHat := append([]float64(nil), r...)
x := make([]float64, n)
v := make([]float64, n)
p := make([]float64, n)
s := make([]float64, n)
t := make([]float64, n)
z := make([]float64, n)
y := make([]float64, n)
mt := make([]float64, n)
rho := 1.0
alpha := 1.0
omega := 1.0
for iter := range maxIter {
rhoNext := dotF64(rHat, r)
if !finiteF64(rhoNext) || rhoNext == 0 {
return nil, base.Errf("SpSolveBiCGSTAB: breakdown at step %d (rho vanished)", iter+1)
}
if iter > 0 {
beta := rhoNext / rho * (alpha / omega)
for i := range n {
p[i] = r[i] + beta*(p[i]-omega*v[i])
}
} else {
copy(p, r)
}
iluPrecondition(z, p, ilu, diag)
c.matVec(z, v)
rhatV := dotF64(rHat, v)
if rhatV == 0 {
return nil, base.Errf("SpSolveBiCGSTAB: breakdown at step %d (direction orthogonal to shadow)", iter+1)
}
alpha = rhoNext / rhatV
for i := range n {
s[i] = r[i] - alpha*v[i]
}
if !vecFinite(s) {
return nil, base.Errf("SpSolveBiCGSTAB: breakdown at step %d (non-finite residual)", iter+1)
}
// The half step may already be exact. The check is affirmative so
// a NaN norm (which fails every comparison) can never read as
// converged.
if norm2F64(s) > tol*bNorm {
iluPrecondition(y, s, ilu, diag)
c.matVec(y, t)
iluPrecondition(mt, t, ilu, diag)
mtS := dotF64(mt, s)
mtT := dotF64(mt, t)
if mtT == 0 {
return nil, base.Errf("SpSolveBiCGSTAB: breakdown at step %d (stabiliser vanished)", iter+1)
}
omega = mtS / mtT
// The finiteness half of the guard belongs before the
// update: a NaN omega poisons x and r below, and the
// all-NaN residual then reads as an exact solve through
// the NaN-skipping norm.
if !finiteF64(omega) {
return nil, base.Errf("SpSolveBiCGSTAB: breakdown at step %d (stabiliser not finite)", iter+1)
}
for i := range n {
x[i] += alpha*z[i] + omega*y[i]
r[i] = s[i] - omega*t[i]
}
if !vecFinite(r) {
return nil, base.Errf("SpSolveBiCGSTAB: non-finite residual at step %d", iter+1)
}
if norm2F64(r) <= tol*bNorm {
return floatsToArray(x, []int{n}), nil
}
if omega == 0 {
return nil, base.Errf("SpSolveBiCGSTAB: stagnation at step %d (omega zero)", iter+1)
}
} else {
for i := range n {
x[i] += alpha * z[i]
}
return floatsToArray(x, []int{n}), nil
}
rho = rhoNext
}
return nil, base.Errf("SpSolveBiCGSTAB: no convergence in %d steps, residual %.3g (tolerance %.3g)",
maxIter, norm2F64(r), tol*bNorm)
}