329 lines
9.2 KiB
Go
329 lines
9.2 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"
|
||
)
|
||
|
||
import "math"
|
||
|
||
// Matrix exponential, evaluated by scaling and squaring over a
|
||
// diagonal Pade approximant (Higham, 2005). That is the construction
|
||
// LAPACK's *ex* routines and every mainstream runtime use, because it
|
||
// is backward stable and needs nothing but matrix products and one LU
|
||
// solve, both of which the library already has.
|
||
//
|
||
// The kernel is generic over the same scalar family the LU kernel runs
|
||
// on, so the complex path stays in complex128 end to end instead of
|
||
// splitting off into a parallel implementation.
|
||
|
||
// expmTheta holds the 1-norm bound below which each Pade degree keeps
|
||
// its approximation error inside double precision (Higham 2005,
|
||
// Table 10.1).
|
||
var expmTheta = map[int]float64{
|
||
3: 1.495585217958292e-2,
|
||
5: 2.539398330063230e-1,
|
||
7: 9.504178996162932e-1,
|
||
9: 2.097847961257068e+0,
|
||
13: 5.371920351148152e+0,
|
||
}
|
||
|
||
// expmCoeffs returns the numerator coefficients b_j of the degree-m
|
||
// diagonal Pade approximant, as the integers of Higham's Table 10.1.
|
||
// The common factor across the row cancels in (V-U)^{-1}(V+U), so the
|
||
// unnormalised values are used directly, exactly as the reference
|
||
// implementations do.
|
||
func expmCoeffs(deg int) []float64 {
|
||
switch deg {
|
||
case 3:
|
||
return []float64{120, 60, 12, 1}
|
||
case 5:
|
||
return []float64{30240, 15120, 3360, 420, 30, 1}
|
||
case 7:
|
||
return []float64{17297280, 8648640, 1995840, 277200, 25200, 1512, 56, 1}
|
||
case 9:
|
||
return []float64{17643225600, 8821612800, 2075673600, 302702400,
|
||
30270240, 2162160, 110880, 3960, 90, 1}
|
||
default:
|
||
return []float64{64764752532480000, 32382376266240000, 7771770303897600,
|
||
1187353796428800, 129060195264000, 10559470521600, 670442572800,
|
||
33522128640, 1323241920, 40840800, 960960, 16380, 182, 1}
|
||
}
|
||
}
|
||
|
||
// padeDegree picks the approximant degree and the number of squarings
|
||
// for a given 1-norm: the smallest degree whose bound covers the norm,
|
||
// else degree 13 with the matrix scaled down by a power of two.
|
||
func padeDegree(nA float64) (deg, s int) {
|
||
for _, d := range []int{3, 5, 7, 9, 13} {
|
||
if nA <= expmTheta[d] {
|
||
return d, 0
|
||
}
|
||
}
|
||
s = max(int(math.Ceil(math.Log2(nA/expmTheta[13]))), 0)
|
||
return 13, s
|
||
}
|
||
|
||
// MatrixExp returns the matrix exponential exp(A) of a square matrix.
|
||
// Ints and float32 promote to float64; a complex matrix answers in
|
||
// complex128. A zero matrix gives the identity, and exp(A) for
|
||
// diagonal A is the exponential of the diagonal.
|
||
func MatrixExp(a *core.Array) (*core.Array, error) {
|
||
if a.NDim() != 2 || a.Shape()[0] != a.Shape()[1] {
|
||
return nil, base.Errf("MatrixExp: needs a square 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
|
||
}
|
||
n := a.Shape()[0]
|
||
if n == 0 {
|
||
return nil, base.Errf("MatrixExp: zero-sized matrix, got shape %s", base.ShapeText(a.Shape()))
|
||
}
|
||
if a.Dtype() == core.Complex {
|
||
m, err := squareComplexMatrix(a, "MatrixExp")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
r, err := expmKernel(m)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
flat := make([]complex128, n*n)
|
||
for i := range n {
|
||
copy(flat[i*n:(i+1)*n], r[i])
|
||
}
|
||
return core.FromComplexes(flat, n, n)
|
||
}
|
||
m, err := squareFloatMatrix(a, "MatrixExp")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
r, err := expmKernel(m)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
flat := make([]float64, n*n)
|
||
for i := range n {
|
||
copy(flat[i*n:(i+1)*n], r[i])
|
||
}
|
||
return floatsToArray(flat, []int{n, n}), nil
|
||
}
|
||
|
||
// expmKernel computes exp(a) by scaling and squaring over a Pade
|
||
// approximant. The approximant is r(A) = (V-U)^{-1}(V+U) where U holds
|
||
// the odd part and V the even part of the numerator polynomial, both
|
||
// evaluated by Horner in A^2; s squarings then undo the scaling.
|
||
func expmKernel[T scalar](a [][]T) ([][]T, error) {
|
||
n := len(a)
|
||
nA := matrixNorm1(a)
|
||
if math.IsNaN(nA) || math.IsInf(nA, 0) {
|
||
// The theta ladder divides by the norm and int(Ceil(...)) of a
|
||
// non-finite value is implementation-defined; the Padé solve on
|
||
// Inf data would return an all-NaN matrix with a nil error.
|
||
return nil, base.Errf("MatrixExp: the matrix holds a non-finite entry (norm %g)", nA)
|
||
}
|
||
deg, s := padeDegree(nA)
|
||
scaled := a
|
||
if s > 0 {
|
||
scaled = matScaleT(a, math.Ldexp(1, -s))
|
||
}
|
||
b := expmCoeffs(deg)
|
||
a2 := matMulT(scaled, scaled)
|
||
// The Horner sweeps alternate between two buffers instead of landing
|
||
// a fresh matrix per coefficient: the product reads both operands
|
||
// and writes the buffer the previous step left free, so the entries
|
||
// the next multiply reads are the ones this one wrote.
|
||
v, vFree := eyeScaled[T](n, b[deg-1]), newSquare[T](n)
|
||
uIn, uFree := eyeScaled[T](n, b[deg]), newSquare[T](n)
|
||
for j := deg - 3; j >= 0; j -= 2 {
|
||
matMulAddEyeInto(vFree, a2, v, b[j])
|
||
v, vFree = vFree, v
|
||
}
|
||
for j := deg - 2; j >= 1; j -= 2 {
|
||
matMulAddEyeInto(uFree, a2, uIn, b[j])
|
||
uIn, uFree = uFree, uIn
|
||
}
|
||
u := matMulT(scaled, uIn)
|
||
r, err := solveRight("MatrixExp", matSubT(v, u), matAddT(v, u))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if s > 0 {
|
||
sq := newSquare[T](n)
|
||
for range s {
|
||
matMulTInto(sq, r, r)
|
||
r, sq = sq, r
|
||
}
|
||
}
|
||
return r, nil
|
||
}
|
||
|
||
// newSquare allocates an n×n matrix in the kernel's element type as n
|
||
// row views over one flat backing slice: two allocations instead of
|
||
// n+1. The rows stay disjoint, so in-place row swaps remain valid.
|
||
func newSquare[T scalar](n int) [][]T {
|
||
back := make([]T, n*n)
|
||
rows := make([][]T, n)
|
||
for i := range n {
|
||
rows[i] = back[i*n : (i+1)*n]
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// realT widens a float64 into the kernel's element type, giving a zero
|
||
// imaginary part when the kernel runs on complex128.
|
||
func realT[T scalar](f float64) T {
|
||
var zero T
|
||
if _, ok := any(zero).(complex128); ok {
|
||
return any(complex(f, 0)).(T)
|
||
}
|
||
return any(f).(T)
|
||
}
|
||
|
||
// eyeScaled returns c times the n×n identity.
|
||
func eyeScaled[T scalar](n int, c float64) [][]T {
|
||
out := newSquare[T](n)
|
||
cc := realT[T](c)
|
||
for i := range n {
|
||
out[i][i] = cc
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matMulAddEyeInto writes a·m + c·I into out, which must be a square
|
||
// matrix of the same order aliasing neither operand.
|
||
func matMulAddEyeInto[T scalar](out, a, m [][]T, c float64) {
|
||
matMulTInto(out, a, m)
|
||
cc := realT[T](c)
|
||
for i := range out {
|
||
out[i][i] += cc
|
||
}
|
||
}
|
||
|
||
// matMulT multiplies two square matrices in the kernel's element type
|
||
// and returns a fresh matrix.
|
||
func matMulT[T scalar](a, b [][]T) [][]T {
|
||
out := newSquare[T](len(a))
|
||
matMulTInto(out, a, b)
|
||
return out
|
||
}
|
||
|
||
// matMulTInto writes the product a·b into out, which must alias neither
|
||
// operand. out arrives holding an earlier product, so it is cleared
|
||
// first: a fresh matrix is zero by construction and the accumulation
|
||
// below depends on that. The loop order keeps the inner product over p
|
||
// contiguous in both operands, matching the dense kernel's cache
|
||
// behaviour, and the zero multiplier is skipped the way the product has
|
||
// always skipped it, so a zero times a non-finite entry contributes
|
||
// nothing rather than a NaN.
|
||
func matMulTInto[T scalar](out, a, b [][]T) {
|
||
n := len(a)
|
||
var zero T
|
||
for i := range n {
|
||
oi := out[i]
|
||
clear(oi)
|
||
ai := a[i]
|
||
for p := range n {
|
||
aip := ai[p]
|
||
if aip == zero {
|
||
continue
|
||
}
|
||
bp := b[p]
|
||
for j := range n {
|
||
oi[j] += aip * bp[j]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// matAddT returns the element-wise sum of two square matrices.
|
||
func matAddT[T scalar](a, b [][]T) [][]T {
|
||
n := len(a)
|
||
out := newSquare[T](n)
|
||
for i := range n {
|
||
oi, ai, bi := out[i], a[i], b[i]
|
||
for j := range n {
|
||
oi[j] = ai[j] + bi[j]
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matSubT returns the element-wise difference of two square matrices.
|
||
func matSubT[T scalar](a, b [][]T) [][]T {
|
||
n := len(a)
|
||
out := newSquare[T](n)
|
||
for i := range n {
|
||
oi, ai, bi := out[i], a[i], b[i]
|
||
for j := range n {
|
||
oi[j] = ai[j] - bi[j]
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matScaleT multiplies every element by the real scalar f.
|
||
func matScaleT[T scalar](a [][]T, f float64) [][]T {
|
||
n := len(a)
|
||
fT := realT[T](f)
|
||
out := newSquare[T](n)
|
||
for i := range n {
|
||
oi, ai := out[i], a[i]
|
||
for j := range n {
|
||
oi[j] = ai[j] * fT
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matrixNorm1 returns the maximum absolute column sum, the norm the
|
||
// scaling step is measured against.
|
||
func matrixNorm1[T scalar](m [][]T) float64 {
|
||
n := len(m)
|
||
maxCol := 0.0
|
||
for j := range n {
|
||
sum := 0.0
|
||
for i := range n {
|
||
sum += absOf(m[i][j])
|
||
}
|
||
// A NaN column sum can never win the strict max below, so it
|
||
// propagates explicitly: the caller reads the norm as the
|
||
// finiteness screen.
|
||
if math.IsNaN(sum) {
|
||
return math.NaN()
|
||
}
|
||
if sum > maxCol {
|
||
maxCol = sum
|
||
}
|
||
}
|
||
return maxCol
|
||
}
|
||
|
||
// solveRight solves a·x = b for row-major square matrices and returns
|
||
// x row-major. a is consumed by the factorisation, so callers hand
|
||
// over a freshly built matrix; the column-oriented solveSystem is
|
||
// reused underneath rather than a second LU being written.
|
||
func solveRight[T scalar](name string, a, b [][]T) ([][]T, error) {
|
||
n := len(a)
|
||
colBack := make([]T, n*n)
|
||
cols := make([][]T, n)
|
||
for j := range n {
|
||
col := colBack[j*n : (j+1)*n]
|
||
for i := range n {
|
||
col[i] = b[i][j]
|
||
}
|
||
cols[j] = col
|
||
}
|
||
sol, err := base.SolveSystem(name, a, cols)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
x := newSquare[T](n)
|
||
for i := range n {
|
||
for j := range n {
|
||
x[i][j] = sol[j][i]
|
||
}
|
||
}
|
||
return x, nil
|
||
}
|