feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user