Files
tensor/linalg/sparsegeneral.go
T
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

328 lines
11 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 (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// The general (non-Hermitian) sparse eigenproblem. Lanczos
// needs symmetry: its three-term recurrence exists because the Krylov
// space of a symmetric operator comes with an orthogonal basis for
// free. A general operator needs the full Arnoldi recurrence, one
// explicit orthogonalisation per column, and its projected matrix is
// upper Hessenberg rather than tridiagonal. Resonance problems in
// electromagnetics and damped oscillation systems produce exactly
// these operators, complex and non-Hermitian included.
//
// Like SpEigen, this is a one-shot Krylov method: the basis runs to
// min(n, max(2k, k+40)) columns, the small Hessenberg eigenproblem is
// solved exactly by the dense EigenGeneral, and the returned pairs are
// Ritz approximations whose accuracy improves with the budget. The
// restart-on-breakdown scheme mirrors lanczos: an exhausted direction
// closes its block with a zero coupling and a fresh orthogonal random
// direction opens the next.
// SpEigenGeneral returns the k eigenvalues of largest magnitude of a
// general real sparse matrix, each with its unit eigenvector: values
// is a complex128 vector ordered by descending magnitude (a real
// matrix may carry complex conjugate pairs), vectors a complex128
// (n, k) array whose column j belongs to values[j]. The matrix must be
// square and real; complex input belongs to SpEigenGeneralComplex,
// symmetric input gets a cheaper answer from SpEigen. gen seeds the
// start vector (nil uses a fixed seed). Like the dense EigenGeneral,
// and unlike the symmetry-checking SpEigen, entries are not screened
// for finiteness: a non-finite entry answers NaN Ritz pairs.
func SpEigenGeneral(s *core.SparseCOO, k int, gen *core.Generator) (values, vectors *core.Array, err error) {
const name = "SpEigenGeneral"
if s.Values.Dtype() == core.Complex {
return nil, nil, base.Errf("%s: complex matrices belong to SpEigenGeneralComplex", name)
}
// The same square 2-D contract SpEigen enforces, and for the same
// reason: the Krylov product indexes x by the stored column, so a
// column beyond the row count reads out of range, and the read
// happens inside a worker goroutine the caller cannot recover.
// Ranks above two are refused too: the index walk would silently
// ignore every dimension past the first two.
if len(s.Shape) != 2 || s.Shape[0] != s.Shape[1] {
return nil, nil, base.Errf("%s: needs a square 2-D sparse matrix, got shape %v", name, s.Shape)
}
c, err := cooToCSR(s, name)
if err != nil {
return nil, nil, err
}
return arnoldiEigen(c.matVec, c.n, k, gen, name, krylovF64)
}
// SpEigenGeneralComplex is SpEigenGeneral for complex128 sparse
// matrices: non-Hermitian operators included, which is the
// electromagnetics case.
func SpEigenGeneralComplex(s *core.SparseCOO, k int, gen *core.Generator) (values, vectors *core.Array, err error) {
const name = "SpEigenGeneralComplex"
c, err := cooToComplexCSR(s, name)
if err != nil {
return nil, nil, err
}
return arnoldiEigen(c.matVec, c.n, k, gen, name, krylovC128)
}
// arnoldiEigen runs the shared pipeline: Arnoldi basis, dense
// eigensolve of the projected Hessenberg, Ritz lift. The matvec
// closure hides whether the operator is real or complex, and kern
// supplies the element-type-specific primitives the basis arithmetic
// needs.
func arnoldiEigen[T scalar](matvec func(x, y []T), n, k int, gen *core.Generator, name string, kern krylovKernel[T]) (*core.Array, *core.Array, error) {
if n == 0 {
return nil, nil, base.Errf("%s: zero-sized matrix", name)
}
if k < 1 || k > n {
return nil, nil, base.Errf("%s: k must be in [1, %d], got %d", name, n, k)
}
if gen == nil {
gen = core.NewGenerator(spEigenSeed)
}
m := min(n, max(2*k, k+spEigenBlock))
h, v := arnoldi(matvec, n, m, gen, kern)
// The projected problem: the leading m×m block of the Hessenberg,
// dense and small enough for the general eigensolver.
hArr, err := zeros(core.Complex, []int{m, m})
if err != nil {
return nil, nil, base.Errf("%s: %w", name, err)
}
hc := hArr.RawComplexes()
for j := range m {
for i := 0; i <= j+1 && i < m; i++ {
hc[i*m+j] = toComplex(h[i*m+j])
}
}
vals, vecs, err := EigenGeneral(hArr)
if err != nil {
return nil, nil, base.Errf("%s: %w", name, err)
}
if k > vals.Len() {
k = vals.Len()
}
outVals, err := zeros(core.Complex, []int{k})
if err != nil {
return nil, nil, base.Errf("%s: %w", name, err)
}
vc := outVals.RawComplexes()
outVecs := make([]complex128, n*k)
tmp := make([]complex128, n)
for j := range k {
vc[j] = vals.ComplexAt(j)
// Ritz vector: lift the Hessenberg eigenvector through the
// Arnoldi basis, u = V·y.
clear(tmp)
for p := range m {
y := vecs.ComplexAt(p*m + j)
if y == 0 {
continue
}
kern.widenAxpy(tmp, v[p*n:(p+1)*n], y)
}
norm := 0.0
for _, z := range tmp {
norm += real(z)*real(z) + imag(z)*imag(z)
}
norm = math.Sqrt(norm)
if norm > 0 {
for i := range n {
tmp[i] /= complex(norm, 0)
}
}
for i := range n {
outVecs[i*k+j] = tmp[i]
}
}
ritzVecs, err := complexFromArray2D(outVecs, n, k)
if err != nil {
return nil, nil, base.Errf("%s: %w", name, err)
}
return outVals, ritzVecs, nil
}
// toComplex widens a scalar kernel value for the complex lift.
func toComplex[T scalar](v T) complex128 {
switch x := any(v).(type) {
case float64:
return complex(x, 0)
case complex128:
return x
}
return 0
}
// arnoldi builds the Krylov basis v (columns of length n, m+1 of
// them) and the upper Hessenberg h ((m+1)×m, row-major) of the
// operator behind matvec. Full reorthogonalisation, twice per column,
// keeps the basis orthonormal to rounding level; a collapsed direction
// closes its block with a zero coupling and restarts in a fresh
// orthogonal random direction.
func arnoldi[T scalar](matvec func(x, y []T), n, m int, gen *core.Generator, kern krylovKernel[T]) (h, v []T) {
// The basis never exceeds the space it spans: m above n would let
// the truncation branch below hand back a shorter h than the m×m
// read loop expects.
if m > n {
panic("arnoldi: the block size exceeds the matrix order")
}
h = make([]T, (m+1)*m)
v = make([]T, n*(m+1))
w := make([]T, n)
scale := 0.0
addScale := func(z T) {
if a := absOf(z); a > scale {
scale = a
}
}
// normT is the one accumulation in the recurrence that squares its
// inputs. A column whose entries are above about 1.3e154 overflows
// the raw sum of squares to +Inf (the projected coupling becomes
// infinite and the Ritz values come back wrong without a word), and
// one below about 1.5e-162 underflows it to zero (the exhaustion
// test reads that as a collapsed Krylov block and closes the block
// on a direction the operator never exhausted). The sum therefore
// runs raw while it lands in the normal range, which keeps the
// ordinary-scale arithmetic bit-for-bit, and falls back to a
// max-scaled accumulation outside it, the way norm2F64 and normC
// do. NaN is carried through the raw path so a poisoned input stays
// poisoned rather than reading as an exhausted block.
normT := func(a []T) float64 {
if s := absOf(kern.dot(a, a)); s <= math.MaxFloat64 && (s >= 0x1p-1022 || math.IsNaN(s)) {
return math.Sqrt(s)
}
maxAbs := 0.0
for _, z := range a {
if m := absOf(z); m > maxAbs {
maxAbs = m
}
}
if maxAbs == 0 {
return 0
}
s := 0.0
for _, z := range a {
r := absOf(z) / maxAbs
s += r * r
}
return maxAbs * math.Sqrt(s)
}
start := func(col int) {
q := v[col*n : (col+1)*n]
kern.fillRand(q, gen)
for range 2 {
for p := range col {
row := v[p*n : (p+1)*n]
d := kern.dot(row, q)
for i := range n {
q[i] -= d * row[i]
}
}
}
if norm := normT(q); norm > 0 {
kern.scaleInto(q, q, 1/norm)
}
}
start(0)
for j := range m {
q := v[j*n : (j+1)*n]
matvec(q, w)
for i := 0; i <= j; i++ {
row := v[i*n : (i+1)*n]
d := kern.dot(row, w)
h[i*m+j] = d
for t := range n {
w[t] -= d * row[t]
}
}
// Full reorthogonalisation, twice: the first pass removes the
// accumulated loss, the second what the first reintroduces.
for range 2 {
for i := 0; i <= j; i++ {
row := v[i*n : (i+1)*n]
d := kern.dot(row, w)
h[i*m+j] += d
for t := range n {
w[t] -= d * row[t]
}
}
}
beta := normT(w)
addScale(h[j*m+j])
if j+1 < m {
if beta <= float64(n)*base.EpsF*scale {
// The Krylov space of this block is exhausted; close it
// with a zero coupling and restart in a fresh
// direction. m never exceeds n (asserted at the top),
// so the basis always has room for the restart column.
start(j + 1)
continue
}
h[(j+1)*m+j] = realT[T](beta)
kern.scaleInto(v[(j+1)*n:(j+2)*n], w, 1/beta)
}
}
return h, v
}
// krylovKernel holds the element-type-specific primitives the shared
// Arnoldi recurrence calls once per vector pass: the conjugated inner
// product, the real scaling of a vector, the start-vector fill and the
// widening lift of a real basis row into the complex Ritz vector.
// Passing them in keeps one copy of the recurrence's arithmetic while
// the element type is dispatched on once per pass instead of once per
// element.
type krylovKernel[T scalar] struct {
dot func(a, b []T) T
scaleInto func(dst, src []T, s float64)
fillRand func(dst []T, gen *core.Generator)
widenAxpy func(dst []complex128, src []T, y complex128)
}
// krylovF64 is the kernel of a real operator.
var krylovF64 = krylovKernel[float64]{
dot: dotF64,
scaleInto: func(dst, src []float64, s float64) {
for i := range dst {
dst[i] = src[i] * s
}
},
fillRand: func(dst []float64, gen *core.Generator) {
for i := range dst {
dst[i] = gen.NormalUnit()
}
},
widenAxpy: func(dst []complex128, src []float64, y complex128) {
for i, v := range src {
dst[i] += y * complex(v, 0)
}
},
}
// krylovC128 is the kernel of a complex operator: the inner product
// conjugates its left operand, and the start vector draws a real and an
// imaginary part from the generator, in that order.
var krylovC128 = krylovKernel[complex128]{
dot: dotC,
scaleInto: func(dst, src []complex128, s float64) {
for i := range dst {
dst[i] = src[i] * complex(s, 0)
}
},
fillRand: func(dst []complex128, gen *core.Generator) {
for i := range dst {
dst[i] = complex(gen.NormalUnit(), gen.NormalUnit())
}
},
widenAxpy: func(dst []complex128, src []complex128, y complex128) {
for i, v := range src {
dst[i] += y * v
}
},
}