Files
tensor/linalg/sparseilu.go
T

198 lines
6.6 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// ILU(0): the incomplete LU factorisation whose L and U factors keep
// exactly the sparsity pattern of A, dropping every fill-in entry the
// complete factorisation would create. The result is not a solver but
// a preconditioner: applying it approximates A⁻¹ at the cost of one
// forward and one backward substitution over the stored pattern, and
// the Krylov solvers converge in far fewer steps than under plain
// Jacobi scaling because the approximation carries the matrix's local
// coupling. For a tridiagonal matrix the complete factorisation
// creates no fill anyway, so ILU(0) equals the complete LU there; the
// wider the stencil, the bigger the accuracy gap and the cheaper the
// factorisation stays.
//
// The factorisation runs the row-oriented IKJ elimination: row i's
// left part scales by the pivots above it, and each elimination
// updates only the positions the pattern already stores, intersecting
// row i with the pivot row by a two-pointer walk over the sorted
// column indices. Cost tracks the non-zero count, not n³.
// SparseILU holds the ILU(0) factorisation of a square sparse matrix
// in its CSR pattern: entries left of the diagonal are L multipliers
// with the unit diagonal implied, entries at or right of it are U
// values including the pivots on the diagonal.
type SparseILU struct {
vals []float64
colIdx []int
rowStart []int
n int
}
// NewSparseILU factors a square sparse matrix with ILU(0). Column
// indices within each row must be sorted ascending, which the COO to
// CSR conversions guarantee. A missing diagonal entry or a zero pivot
// is refused: the factorisation divides by both, and an incomplete
// factorisation cannot repair a structurally singular matrix.
// Non-finite entries are refused, exactly as NewSparseLU and
// NewSparseCholesky refuse them.
func NewSparseILU(a *core.SparseCOO) (*SparseILU, error) {
if a.Values.Dtype() == core.Complex {
return nil, base.Errf("NewSparseILU: complex sparse matrices are not supported")
}
if len(a.Shape) != 2 || a.Shape[0] != a.Shape[1] {
return nil, base.Errf("NewSparseILU: needs a square 2-D sparse matrix, got shape %v", a.Shape)
}
c, err := cooToCSR(a, "NewSparseILU")
if err != nil {
return nil, err
}
for i := range c.n {
for p := c.rowStart[i]; p < c.rowStart[i+1]; p++ {
v := c.vals[p]
if math.IsNaN(v) || math.IsInf(v, 0) {
return nil, base.Errf("NewSparseILU: entry [%d,%d] is not finite", i, c.colIdx[p])
}
}
}
n := c.n
// Every row needs a pivot: the forward substitution divides by the
// pivots above the diagonal and the backward substitution by every
// diagonal, so a row without one is structurally singular. The
// elimination loop below only inspects the diagonals a later row
// actually pivots on, which never includes the last row (and misses
// any other row nothing below it references), so the scan is what
// makes the documented refusal hold for the whole matrix.
for i := range n {
if c.atDiagonal(i) == 0 {
return nil, base.Errf("NewSparseILU: missing or zero diagonal at row %d", i)
}
}
for i := 1; i < n; i++ {
rs, re := c.rowStart[i], c.rowStart[i+1]
// Walk row i's left part; every pivot row k < i is final by
// the time row i is processed.
for p := rs; p < re && c.colIdx[p] < i; p++ {
k := c.colIdx[p]
pivot := c.atDiagonal(k)
if pivot == 0 {
return nil, base.Errf("NewSparseILU: zero pivot at row %d", k)
}
mult := c.vals[p] / pivot
c.vals[p] = mult
// Two-pointer intersection of row i right of column k
// with row k right of column k. The elimination can
// overflow on legal finite input (the siblings in LU and
// Cholesky refuse the same), and Apply has no error
// return, so a poisoned factor must never leave here.
if math.IsInf(mult, 0) || math.IsNaN(mult) {
return nil, base.Errf("NewSparseILU: the elimination overflowed at row %d, column %d (multiplier %g)", i, k, mult)
}
q := c.rowStart[k]
kre := c.rowStart[k+1]
for q < kre && c.colIdx[q] <= k {
q++
}
t := p + 1
for t < re && q < kre {
switch {
case c.colIdx[t] == c.colIdx[q]:
c.vals[t] -= mult * c.vals[q]
if math.IsInf(c.vals[t], 0) || math.IsNaN(c.vals[t]) {
return nil, base.Errf("NewSparseILU: the elimination overflowed at row %d, column %d", i, c.colIdx[t])
}
t++
q++
case c.colIdx[t] < c.colIdx[q]:
t++
default:
q++
}
}
}
}
return &SparseILU{
vals: c.vals,
colIdx: c.colIdx,
rowStart: c.rowStart,
n: n,
}, nil
}
// atDiagonal returns the diagonal entry of row i, or 0 when absent.
func (c *sparseCSR) atDiagonal(i int) float64 {
for p := c.rowStart[i]; p < c.rowStart[i+1] && c.colIdx[p] <= i; p++ {
if c.colIdx[p] == i {
return c.vals[p]
}
}
return 0
}
// Apply solves (L·U)·x = r over the stored pattern: a forward
// substitution with unit diagonal, then a backward substitution with
// the pivots. The result approximates A⁻¹·r to the accuracy the
// dropped fill allows.
func (ilu *SparseILU) Apply(r []float64) []float64 {
dst := make([]float64, ilu.n)
ilu.applyTo(dst, r)
return dst
}
// applyTo writes (L·U)⁻¹·r into dst, which must be a buffer of the
// factor's dimension distinct from r. Both substitutions run in place:
// the forward pass overwrites each entry only after reading the ones
// below it, and the backward pass reads dst[i] before overwriting it
// with y[i], so an application allocates nothing.
func (ilu *SparseILU) applyTo(dst, r []float64) {
n := ilu.n
copy(dst, r)
for i := range n {
s := dst[i]
for p := ilu.rowStart[i]; p < ilu.rowStart[i+1] && ilu.colIdx[p] < i; p++ {
s -= ilu.vals[p] * dst[ilu.colIdx[p]]
}
dst[i] = s
}
for i := n - 1; i >= 0; i-- {
s := dst[i]
var diag float64
for p := ilu.rowStart[i]; p < ilu.rowStart[i+1]; p++ {
j := ilu.colIdx[p]
if j == i {
diag = ilu.vals[p]
} else if j > i {
s -= ilu.vals[p] * dst[j]
}
}
if diag == 0 {
// NewSparseILU refuses zero pivots, so this is defensive.
diag = 1
}
dst[i] = s / diag
}
}
// iluPrecondition writes the preconditioned vector of r into dst: the
// caller's ILU factor when one was given, otherwise the Jacobi scaling
// by the diagonal.
func iluPrecondition(dst, r []float64, ilu *SparseILU, diag []float64) {
if ilu != nil {
ilu.applyTo(dst, r)
return
}
for i := range r {
dst[i] = r[i] / diag[i]
}
}