Skip to content

Commit dad6197

Browse files
Jutholkdvos
andauthored
Taylor series native exponential implementation (#243)
Co-authored-by: lkdvos <ldevos98@gmail.com>
1 parent 4443df0 commit dad6197

8 files changed

Lines changed: 277 additions & 7 deletions

File tree

docs/src/user_interface/algorithms.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ The following algorithms for matrix functions are available.
102102

103103
| Algorithm | Applicable matrix functions | Key keyword arguments |
104104
|:----------|:--------------------------|:----------------------|
105+
| [`MatrixFunctionViaTaylor`](@ref) | exponential | `tol`, `balance` |
105106
| [`MatrixFunctionViaLA`](@ref) | exponential | |
106107
| [`MatrixFunctionViaEig`](@ref) | exponential | `eig_alg` |
107108
| [`MatrixFunctionViaEigh`](@ref) | exponential | `eigh_alg` |

docs/src/user_interface/matrix_functions.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@ Additionally, the `f!` method typically assumes that it is allowed to destroy th
2323
## Exponential
2424

2525
The [exponential](https://en.wikipedia.org/wiki/Matrix_exponential) of a square matrix `A` is used in many scientific applications, as it arises in the solution of an autonomous linear differential equation.
26-
An implementation for the matrix exponential based on a Padé approximation is available in `LinearAlgebra`, and can be accessed by the algorithm [`MatrixFunctionViaLA`](@ref).
27-
For more generic data types, the exponential can be calculated by first calculating the (hermitian) eigenvalue decomposition, and then computing
28-
the scalar exponential of the diagonal elements.
26+
The default algorithm [`MatrixFunctionViaTaylor`](@ref) is a pure-Julia scaling-and-squaring evaluation of the Taylor series.
27+
As it requires no LAPACK support, it also applies to generic data types at arbitrary precision.
28+
Alternatively, an implementation based on a Padé approximation is available in `LinearAlgebra`, and can be accessed by the algorithm [`MatrixFunctionViaLA`](@ref).
29+
The exponential can also be calculated by first calculating the (hermitian) eigenvalue decomposition, and then computing the scalar exponential of the diagonal elements.
2930
This strategy is implemented via the algorithms [`MatrixFunctionViaEig`](@ref) and [`MatrixFunctionViaEigh`](@ref), and call `eig_full` and `eigh_full`, respectively.
3031
Additionally, in order to calculate `exp(τ * A)`, the function `exponential` can be called with `(τ, A)`, using the same algorithms as before.
3132

3233
```@docs; canonical=false
3334
exponential
35+
MatrixAlgebraKit.MatrixFunctionViaTaylor
3436
MatrixAlgebraKit.MatrixFunctionViaLA
3537
MatrixAlgebraKit.MatrixFunctionViaEig
3638
MatrixAlgebraKit.MatrixFunctionViaEigh

src/MatrixAlgebraKit.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export LAPACK_HouseholderQR, LAPACK_HouseholderLQ, LAPACK_Simple, LAPACK_Expert,
4141
export GLA_HouseholderQR, GLA_QRIteration, GS_QRIteration
4242
export LQViaTransposedQR
4343
export PolarViaSVD, PolarNewton
44-
export MatrixFunctionViaLA, MatrixFunctionViaEig, MatrixFunctionViaEigh
44+
export MatrixFunctionViaLA, MatrixFunctionViaEig, MatrixFunctionViaEigh, MatrixFunctionViaTaylor
4545
export DefaultAlgorithm
4646
export DiagonalAlgorithm
4747
export NativeBlocked
@@ -94,6 +94,7 @@ include("common/safemethods.jl")
9494
include("common/view.jl")
9595
include("common/regularinv.jl")
9696
include("common/matrixproperties.jl")
97+
include("common/balancing.jl")
9798
include("common/utility.jl")
9899

99100
include("yalapack.jl")

src/common/balancing.jl

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
balance!(A::AbstractMatrix; maxiter=100) -> A, scale
3+
4+
Balance the square matrix `A` in place through a diagonal similarity `A ← D⁻¹ A D` that reduces its norm.
5+
Each sweep computes, for every index at once, the power-of-two factor that best equalizes the
6+
off-diagonal row and column norms (a simultaneous, Osborne-style variant of the Parlett–Reinsch scaling),
7+
and applies all factors together; sweeps repeat until no index changes or `maxiter` is reached.
8+
9+
The scaling factors are integer powers of two, the machine radix, so that the transformation is exact even
10+
in floating point arithmetic. The row and column norms are simultaneously adapted to avoid scalar indexing
11+
and lots of kernel calls on GPUs.
12+
13+
The returned `scale` holds the diagonal of `D`, so that a matrix function of the original
14+
input can be recovered from a matrix function `f` of the balanced matrix through
15+
`D f(D⁻¹AD) D⁻¹`, i.e. `expA[i, j] = scale[i] * f(B)[i, j] / scale[j]`.
16+
"""
17+
function balance!(A::AbstractMatrix{T}; maxiter::Integer = 100) where {T}
18+
n = LinearAlgebra.checksquare(A)
19+
R = real(T)
20+
β = convert(R, 2)
21+
logβ = log(β)
22+
scale = fill!(similar(A, R, n), one(R))
23+
24+
colnorm = similar(A, R, n)
25+
rownorm = similar(A, R, n)
26+
f = similar(A, R, n)
27+
colsum = reshape(colnorm, 1, n)
28+
rowsum = reshape(rownorm, n, 1)
29+
d = abs.(diagview(A))
30+
fᵀ = transpose(f)
31+
32+
for _ in 1:maxiter
33+
fill!(colsum, zero(R))
34+
Base.mapreducedim!(abs, +, colsum, A)
35+
fill!(rowsum, zero(R))
36+
Base.mapreducedim!(abs, +, rowsum, A)
37+
colnorm .-= d
38+
rownorm .-= d
39+
f .= _balance_factor.(colnorm, rownorm, β, logβ)
40+
all(isone, f) && break
41+
# apply Aᵢⱼ ← Aᵢⱼ fⱼ / fᵢ (i.e. column j scaled by fⱼ, row i by 1/fᵢ) and accumulate.
42+
A .= A .* fᵀ ./ f
43+
scale .*= f
44+
end
45+
46+
return A, scale
47+
end
48+
49+
# Nearest power-of-`β` factor `f` that equalizes the scaled off-diagonal norms
50+
# `colnorm·f` and `rownorm/f`, kept only when it reduces their sum (avoids oscillation and
51+
# leaves degenerate rows/columns untouched).
52+
@inline function _balance_factor(colnorm::R, rownorm::R, β::R, logβ::R) where {R}
53+
(colnorm > 0 && rownorm > 0) || return one(R)
54+
f = β^round(log(rownorm / colnorm) / (2 * logβ))
55+
return (colnorm * f + rownorm / f) < convert(R, 19 // 20) * (colnorm + rownorm) ? f : one(R)
56+
end

src/implementations/exponential.jl

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,154 @@ function exponential!((τ, A)::Tuple{Number, AbstractMatrix}, expA, alg::Diagona
114114
check_input(exponential!, (τ, A), expA, alg)
115115
return map_diagonal!(x -> exp(x * τ), expA, A)
116116
end
117+
118+
# Taylor logic
119+
# ------------
120+
function exponential!(A::AbstractMatrix, expA, alg::MatrixFunctionViaTaylor)
121+
check_input(exponential!, A, expA, alg)
122+
m = LinearAlgebra.checksquare(A)
123+
T = eltype(A)
124+
R = real(T)
125+
tol = convert(R, get(alg.kwargs, :tol, eps(R)))
126+
127+
dobalance = get(alg.kwargs, :balance, false)
128+
scale = dobalance ? balance!(A)[2] : nothing
129+
130+
# Form a minimal set of powers of A up front and use them to sharpen the norm estimate
131+
# through the Al-Mohy–Higham quantities dₚ = ‖Aᵖ‖^(1/p).
132+
p₀ = min(convert(Int, get(alg.kwargs, :estimate_order, 4)), m)
133+
powers = Vector{typeof(A)}(undef, p₀)
134+
powers[1] = A
135+
d = Vector{R}(undef, p₀)
136+
d[1] = LinearAlgebra.opnorm(A, 1)
137+
iszero(d[1]) && return one!(expA)
138+
for p in 2:p₀
139+
powers[p] = powers[p - 1] * A
140+
d[p] = LinearAlgebra.opnorm(powers[p], 1)^(1 / p)
141+
end
142+
143+
order, blocksize, squarings = taylor_order_and_squarings(d, tol)
144+
145+
# Rescale the p₀ powers we hold into powers of A/2ˢ
146+
if squarings > 0
147+
f = inv(convert(T, 2)^squarings)
148+
fk = one(T)
149+
for k in 1:p₀
150+
fk *= f
151+
rmul!(powers[k], fk) # powers[k] ← (A/2ˢ)ᵏ
152+
end
153+
end
154+
# Extend from p₀ to `blocksize` powers (blocksize ≥ p₀ always, see taylor_order_and_squarings)
155+
for p in (p₀ + 1):blocksize
156+
push!(powers, powers[p - 1] * powers[1])
157+
end
158+
159+
160+
# Paterson–Stockmeyer evaluation, followed by squaring
161+
X = taylor_polynomial(powers, order)
162+
Y = expA # can reuse this memory
163+
for _ in 1:squarings
164+
mul!(Y, X, X)
165+
X, Y = Y, X
166+
end
167+
168+
if dobalance
169+
expA .= scale .* X ./ transpose(scale)
170+
else
171+
X === expA || copyto!(expA, X)
172+
end
173+
return expA
174+
end
175+
176+
# Truncation order `m`, blocksize `b` and number of squarings `s` minimizing the total
177+
# matrix-multiplication count needed to make the Taylor remainder bound `(θ/2ˢ)ᵐ⁺¹/(m+1)! ≤ tol`.
178+
function taylor_order_and_squarings(d::AbstractVector{<:Real}, tol::Real)
179+
p₀ = length(d)
180+
log2tol = Float64(log2(tol)) # log2 before conversion: high-precision `tol` may underflow Float64
181+
log2factorial = 0.0 # log2((order + 1)!), accumulated incrementally across increasing orders
182+
prev_order = best_order = best_blocksize = best_squarings = 0
183+
best_cost = typemax(Int)
184+
185+
budget = 0 # multiplications spent on powers and Horner steps, excluding squarings
186+
while budget < best_cost # total cost ≥ budget, so larger budgets cannot improve
187+
# highest order = numblocks * blocksize reachable within `budget`: the sum
188+
# numblocks + blocksize is fixed, so balance the split, keeping blocksize ≥ p₀
189+
blocksize = max(p₀, (budget + p₀ + 1) ÷ 2)
190+
numblocks = budget + p₀ + 1 - blocksize
191+
order = numblocks * blocksize
192+
193+
# sharpest valid αₚ = min over p with p(p-1) ≤ order+1 and p+1 ≤ p₀
194+
# Al-Mohy, A. H. and Higham, N. J., "A New Scaling and Squaring Algorithm for the Matrix Exponential",
195+
# SIAM J. Matrix Anal. Appl., 31(3), 970–989, 2009, Lemma 4.1 and Theorem 4.2(a).
196+
θ = d[1]
197+
for p in 1:(p₀ - 1)
198+
p * (p - 1) order + 1 || break
199+
θ = min(θ, max(d[p], d[p + 1]))
200+
end
201+
202+
# compute squarings needed to make `(θ/2ˢ)ᵐ⁺¹/(m+1)! ≤ tol`
203+
for k in (prev_order + 2):(order + 1)
204+
log2factorial += log2(k)
205+
end
206+
prev_order = order
207+
excess = Float64(log2(θ)) - (log2tol + log2factorial) / (order + 1)
208+
squarings = excess > 0 ? ceil(Int, excess) : 0 # be careful with θ = 0
209+
210+
211+
cost = budget + squarings
212+
if cost best_cost # ties → larger budget → fewer squarings (more accurate)
213+
best_cost = cost
214+
best_order = order
215+
best_blocksize = blocksize
216+
best_squarings = squarings
217+
end
218+
budget += 1
219+
end
220+
221+
return best_order, best_blocksize, best_squarings
222+
end
223+
224+
# Evaluate ∑ₖ₌₀ᵐ Aᵏ/k! via the Paterson–Stockmeyer scheme, returning a freshly allocated matrix.
225+
# `powers` holds A, A², …, A^blocksize (already scaled). The polynomial is split as
226+
# c₀ I + ∑ⱼ₌₁ᴷ A^((j-1)b) Bⱼ with blocks Bⱼ = ∑ᵢ₌₁ᵇ c_{(j-1)b+i} Aⁱ and K = cld(order, blocksize),
227+
# evaluated by Horner over A^b using K - 1 multiplications; the constant term is added to the
228+
# diagonal at the end.
229+
function taylor_polynomial(powers::AbstractVector{<:AbstractMatrix}, order::Integer)
230+
A = powers[1]
231+
T = eltype(A)
232+
blocksize = length(powers)
233+
234+
invfactorial = Vector{T}(undef, order + 1)
235+
invfactorial[1] = one(T)
236+
for k in 1:order
237+
invfactorial[k + 1] = invfactorial[k] / k
238+
end
239+
240+
result = similar(A)
241+
block = similar(A)
242+
numblocks = cld(order, blocksize)
243+
taylor_block!(result, powers, invfactorial, numblocks, blocksize, order)
244+
for blockindex in (numblocks - 1):-1:1
245+
mul!(block, result, powers[blocksize])
246+
taylor_block!(result, powers, invfactorial, blockindex, blocksize, order)
247+
result .+= block
248+
end
249+
diagview(result) .+= invfactorial[1]
250+
return result
251+
end
252+
253+
# Block Bⱼ = ∑ᵢ₌₁ᵇ c_{offset+i} Aⁱ with offset = (blockindex - 1) * blocksize, truncated at
254+
# total degree `order`, written into `out`.
255+
function taylor_block!(out::AbstractMatrix, powers, invfactorial, blockindex::Integer, blocksize::Integer, order::Integer)
256+
offset = (blockindex - 1) * blocksize
257+
for i in 1:blocksize
258+
k = offset + i
259+
k > order && break
260+
if i == 1
261+
out .= invfactorial[k + 1] .* powers[i]
262+
else
263+
out .+= invfactorial[k + 1] .* powers[i]
264+
end
265+
end
266+
return out
267+
end

src/interface/exponential.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Compute the exponential of the square matrix `A` or `τ * A`,
2424
# -------------------
2525
default_exponential_algorithm(A; kwargs...) = default_exponential_algorithm(typeof(A); kwargs...)
2626
function default_exponential_algorithm(T::Type; kwargs...)
27-
return MatrixFunctionViaLA(; kwargs...)
27+
return MatrixFunctionViaTaylor(; kwargs...)
2828
end
2929
function default_exponential_algorithm(::Type{T}; kwargs...) where {T <: Diagonal}
3030
return DiagonalAlgorithm(; kwargs...)

src/interface/matrixfunctions.jl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ Algorithm type to denote finding the exponential of `A` via the implementation o
88
"""
99
@algdef MatrixFunctionViaLA
1010

11+
"""
12+
MatrixFunctionViaTaylor(; tol=eps, balance=true, estimate_order=4)
13+
14+
Algorithm type to denote finding the exponential of `A` through a pure-Julia scaling-and-squaring
15+
evaluation of its Taylor series, following Fasi & Higham (2018).
16+
The truncation order and the number of squarings are chosen to reach a relative accuracy `tol`,
17+
and the Taylor polynomial is evaluated with the Paterson–Stockmeyer scheme.
18+
When `balance` is `true`, `A` is first balanced by a diagonal similarity.
19+
`estimate_order` sets how many powers of `A` are formed up front to sharpen the norm estimate via the
20+
Al-Mohy–Higham quantities `‖Aᵖ‖^(1/p)` (Al-Mohy & Higham, 2009); these powers are reused by the
21+
Paterson–Stockmeyer evaluation.
22+
As this algorithm requires no LAPACK support, it also applies at arbitrary precision.
23+
24+
## References
25+
26+
- A. H. Al-Mohy and N. J. Higham, "A New Scaling and Squaring Algorithm for the Matrix
27+
Exponential", SIAM J. Matrix Anal. Appl., 31(3), 970–989, 2009.
28+
"""
29+
@algdef MatrixFunctionViaTaylor
30+
1131
"""
1232
MatrixFunctionViaEigh(eigh_alg)
1333

test/exponential.jl

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ using StableRNGs
55
using MatrixAlgebraKit: diagview
66
using LinearAlgebra
77
using LinearAlgebra: exp
8+
using CUDA, AMDGPU
89

910
BLASFloats = (Float32, Float64, ComplexF32, ComplexF64)
1011
GenericFloats = (Float16, ComplexF16, BigFloat, Complex{BigFloat})
@@ -21,7 +22,7 @@ GenericFloats = (Float16, ComplexF16, BigFloat, Complex{BigFloat})
2122
@test expA expA2
2223
@test A == Ac
2324

24-
algs = (MatrixFunctionViaLA(), MatrixFunctionViaEig(LAPACK_Simple()))
25+
algs = (MatrixFunctionViaLA(), MatrixFunctionViaEig(LAPACK_Simple()), MatrixFunctionViaTaylor())
2526
@testset "algorithm $alg" for alg in algs
2627
expA2 = @constinferred exponential(A, alg)
2728
@test expA expA2
@@ -46,7 +47,7 @@ end
4647
@test expAτ expAτ2
4748
@test A == Ac
4849

49-
algs = (MatrixFunctionViaLA(), MatrixFunctionViaEig(LAPACK_Simple()))
50+
algs = (MatrixFunctionViaLA(), MatrixFunctionViaEig(LAPACK_Simple()), MatrixFunctionViaTaylor())
5051
@testset "algorithm $alg" for alg in algs
5152
expAτ2 = @constinferred exponential((τ, A), alg)
5253
@test expAτ expAτ2
@@ -86,3 +87,41 @@ end
8687
@test expAτ expAτ2
8788
@test A == Ac
8889
end
90+
91+
# GPU tests
92+
# ---------
93+
# The Taylor exponential is backend-generic, so the same code runs on GPU. Compare device
94+
# results against the CPU reference, exercising both `balance` settings (fix 1 & 3) and the
95+
# scaled `(τ, A)` entrypoint. A badly-scaled matrix exercises the balancing path. If any step
96+
# fell back to scalar indexing these would error under GPUArrays' scalar-indexing guard.
97+
function test_exponential_gpu(ArrayT, T)
98+
rng = StableRNG(123)
99+
m = 54
100+
A = randn(rng, T, m, m) ./ (2 * m)
101+
τ = randn(rng, T)
102+
103+
# badly-scaled similarity transform Aᵢⱼ ← Aᵢⱼ sᵢ / sⱼ, to give balancing work to do
104+
s = exp10.(range(-real(T)(3), real(T)(3), length = m))
105+
Abad = A .* s ./ transpose(s)
106+
107+
for M in (A, Abad)
108+
M_gpu = ArrayT(M)
109+
for alg in (MatrixFunctionViaTaylor(), MatrixFunctionViaTaylor(; balance = false))
110+
@test Array(exponential(M_gpu, alg)) exponential(M, alg)
111+
@test Array(exponential((τ, M_gpu), alg)) exponential((τ, M), alg)
112+
end
113+
end
114+
return nothing
115+
end
116+
117+
if CUDA.functional()
118+
@testset "exponential on CUDA for T = $T" for T in BLASFloats
119+
test_exponential_gpu(CuArray, T)
120+
end
121+
end
122+
123+
if AMDGPU.functional()
124+
@testset "exponential on AMDGPU for T = $T" for T in BLASFloats
125+
test_exponential_gpu(ROCArray, T)
126+
end
127+
end

0 commit comments

Comments
 (0)