208 lines
7.3 KiB
Go
208 lines
7.3 KiB
Go
// 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"
|
||
)
|
||
|
||
// Sparse symmetric positive-definite solve. The dense `Solve` factors
|
||
// A once and back-substitutes, which costs O(n³) time and O(n²) memory
|
||
// and is the right answer for a modest, dense system. When A is large
|
||
// and sparse that factorisation is both unaffordable and wasteful: the
|
||
// fill-in alone can exceed the memory the non-zeros needed. SpSolve
|
||
// runs the preconditioned conjugate gradient instead, whose every step
|
||
// is one sparse-matrix-vector product plus a handful of vector
|
||
// operations, so the cost tracks the non-zero count rather than the
|
||
// dimension.
|
||
//
|
||
// Conjugate gradient applies only to a symmetric positive-definite
|
||
// matrix, and it is not a drop-in for `Solve`: the answer is an
|
||
// approximation within a stated residual tolerance, not the exact
|
||
// solution a factorisation returns. Symmetry is verified the same way
|
||
// `SpEigen` verifies it. Positive-definiteness cannot be checked
|
||
// cheaply up front, so it is caught in flight: a non-positive search
|
||
// curvature is the matrix declaring itself indefinite.
|
||
|
||
// spSolveTol is the default relative residual when the caller passes
|
||
// zero. The iteration default is the dimension rather than a constant:
|
||
// an exact arithmetic conjugate gradient terminates in at most n
|
||
// steps, so a larger budget cannot buy convergence, only round-off
|
||
// work.
|
||
const spSolveTol = 1e-10
|
||
|
||
// checkSparseSquare validates the inputs every real sparse solver
|
||
// shares: a is a square 2-D matrix of non-complex dtype and, when b is
|
||
// not nil, b is a real right-hand side vector of the matrix dimension.
|
||
// It returns the dimension n.
|
||
func checkSparseSquare(name string, a *core.SparseCOO, b *core.Array) (int, error) {
|
||
if a.Values.Dtype() == core.Complex {
|
||
return 0, base.Errf("%s: complex sparse matrices are not supported", name)
|
||
}
|
||
if len(a.Shape) != 2 || a.Shape[0] != a.Shape[1] {
|
||
return 0, base.Errf("%s: needs a square 2-D sparse matrix, got shape %v", name, a.Shape)
|
||
}
|
||
n := a.Shape[0]
|
||
if n == 0 {
|
||
return 0, base.Errf("%s: zero-sized matrix, got shape %v", name, a.Shape)
|
||
}
|
||
if b != nil {
|
||
if b.NDim() != 1 || b.Shape()[0] != n {
|
||
return 0, base.Errf("%s: right-hand side must be a vector of length %d, got shape %s",
|
||
name, n, base.ShapeText(b.Shape()))
|
||
}
|
||
if b.Dtype() == core.Complex {
|
||
return 0, base.Errf("%s: complex right-hand side is not supported", name)
|
||
}
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// pickPreconditioner resolves the optional ILU argument of the Krylov
|
||
// solvers, falling back to the Jacobi diagonal of c when none was
|
||
// given. An ILU built for another dimension is refused: applying it
|
||
// would index past its vectors, or precondition with the wrong
|
||
// factorisation without a word.
|
||
func pickPreconditioner(name string, c *sparseCSR, precond []*SparseILU) (*SparseILU, []float64, error) {
|
||
if len(precond) > 0 {
|
||
if precond[0] == nil {
|
||
return nil, nil, base.Errf("%s: the preconditioner is nil", name)
|
||
}
|
||
if precond[0].n != c.n {
|
||
return nil, nil, base.Errf("%s: the preconditioner was built for dimension %d, the system is %d",
|
||
name, precond[0].n, c.n)
|
||
}
|
||
return precond[0], nil, nil
|
||
}
|
||
diag, err := c.diagonal(name)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return nil, diag, nil
|
||
}
|
||
|
||
// vectorF64 flattens a rank-1 array into a plain float64 working
|
||
// vector of the expected length n.
|
||
func vectorF64(b *core.Array, n int) []float64 {
|
||
r := make([]float64, n)
|
||
for i := range n {
|
||
r[i] = b.FloatAt(i)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// SpSolve returns the vector x solving A·x = b for a real symmetric
|
||
// positive-definite sparse A, by preconditioned conjugate gradient
|
||
// with a Jacobi (diagonal) preconditioner. The right-hand side b is a
|
||
// rank-1 vector of length n.
|
||
//
|
||
// The stopping rule is the relative residual ‖b − A·x‖₂ ≤ tol·‖b‖₂.
|
||
// A tol ≤ 0 uses 1e-10, and maxIter ≤ 0 uses n steps. x starts at
|
||
// zero, so the initial residual is b itself and the loop needs no
|
||
// separate first matvec. The default preconditioner is Jacobi
|
||
// scaling; an ILU(0) factorisation from NewSparseILU may be passed to
|
||
// replace it.
|
||
//
|
||
// An unconverged solve is an error naming the residual achieved, with
|
||
// no estimate returned, rather than a silent approximation.
|
||
// The matrix must be symmetric, and a zero diagonal entry is refused:
|
||
// the Jacobi preconditioner divides by it, and for a symmetric
|
||
// positive-definite matrix the diagonal is necessarily positive.
|
||
func SpSolve(a *core.SparseCOO, b *core.Array, tol float64, maxIter int, precond ...*SparseILU) (*core.Array, error) {
|
||
const name = "SpSolve"
|
||
n, err := checkSparseSquare(name, a, b)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
c, err := symmetricCSR(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
|
||
}
|
||
|
||
// x starts at zero, so r = b − A·x is just b and the loop needs no
|
||
// opening matvec.
|
||
r := vectorF64(b, n)
|
||
bNorm := norm2F64(r)
|
||
if bNorm == 0 {
|
||
// The exact solution of A·0 = 0 is the zero vector; a relative
|
||
// stopping rule would otherwise divide by zero.
|
||
return core.Zeros(core.Float, n)
|
||
}
|
||
|
||
z := make([]float64, n)
|
||
iluPrecondition(z, r, ilu, diag)
|
||
p := append([]float64(nil), z...)
|
||
rz := dotF64(r, z)
|
||
x := make([]float64, n)
|
||
ap := make([]float64, n)
|
||
|
||
for iter := range maxIter {
|
||
c.matVec(p, ap)
|
||
pAp := dotF64(p, ap)
|
||
if !finiteF64(pAp) || pAp <= 0 {
|
||
// A non-positive or non-finite curvature means the matrix
|
||
// is not positive-definite, or has overflowed the
|
||
// representable range; either way conjugate gradient
|
||
// cannot proceed. A non-finite value must be an error,
|
||
// not a NaN that slips through the <= 0 test.
|
||
return nil, base.Errf("%s: matrix is not positive-definite (curvature %.3g at step %d)",
|
||
name, pAp, iter+1)
|
||
}
|
||
alpha := rz / pAp
|
||
for i := range n {
|
||
x[i] += alpha * p[i]
|
||
r[i] -= alpha * ap[i]
|
||
}
|
||
// norm2F64 skips NaN entries, so an all-NaN residual reads as a
|
||
// zero norm; a non-finite state is a breakdown before the
|
||
// convergence test can mistake it for an exact solve.
|
||
if !vecFinite(r) {
|
||
return nil, base.Errf("%s: non-finite residual at step %d", name, iter+1)
|
||
}
|
||
if norm2F64(r) <= tol*bNorm {
|
||
return floatsToArray(x, []int{n}), nil
|
||
}
|
||
iluPrecondition(z, r, ilu, diag)
|
||
rzNext := dotF64(r, z)
|
||
if rz == 0 {
|
||
return nil, base.Errf("%s: breakdown at step %d (preconditioned residual vanished)", name, iter+1)
|
||
}
|
||
beta := rzNext / rz
|
||
for i := range n {
|
||
p[i] = z[i] + beta*p[i]
|
||
}
|
||
rz = rzNext
|
||
}
|
||
return nil, base.Errf("%s: no convergence in %d steps, residual %.3g (tolerance %.3g)",
|
||
name, maxIter, norm2F64(r), tol*bNorm)
|
||
}
|
||
|
||
// diagonal returns the main diagonal, which the Jacobi preconditioner
|
||
// divides by. A missing or zero entry makes the preconditioner
|
||
// undefined and, for a symmetric positive-definite matrix, signals
|
||
// that the input is not one.
|
||
func (c *sparseCSR) diagonal(name string) ([]float64, error) {
|
||
d := make([]float64, c.n)
|
||
for i := range c.n {
|
||
v, ok := c.at(i, i)
|
||
if !ok || v == 0 {
|
||
return nil, base.Errf("%s: zero or missing diagonal entry at %d", name, i)
|
||
}
|
||
d[i] = v
|
||
}
|
||
return d, nil
|
||
}
|