Skip to content

Fix implementations of eigen and eigvals - #757

Open
devmotion wants to merge 7 commits into
masterfrom
dw/eigen
Open

Fix implementations of eigen and eigvals#757
devmotion wants to merge 7 commits into
masterfrom
dw/eigen

Conversation

@devmotion

Copy link
Copy Markdown
Member

The PR adds missing definitions of eigvals and eigen for Hermitian{<:Dual} and of eigen for Hermitian{<:Complex{<:Dual}}.
Additionally, the PR adds more tests of eigen and eigvals, unifies the existing implementations, and removes duplicate calculations in the definitions of eigen.

Fixes#756 without GenericLinearAlgebra.


For the example in #756, I get on ForwardDiff@0.10 without GenericLinearAlgebra:

julia>test_hessian(Float64)
ERROR: MethodError: no method matching eigvals!(::Hermitian{ForwardDiff.Dual{…}, Matrix{…}}; sortby::Nothing)
The function`eigvals!` exists, but no method is defined for this combination of argument types.
...
julia>test_hessian(ComplexF64)
ERROR: MethodError: no method matching eigen!(::Hermitian{Complex{ForwardDiff.Dual{…}}, Matrix{Complex{…}}}; sortby::Nothing)
The function`eigen!` exists, but no method is defined for this combination of argument types.
...

And on ForwardDiff@0.10 with import GenericLinearAlgebra: eigen:

julia>@timetest_hessian(Float64)
0.000071 seconds (38 allocations:33.750 KiB)
9×9 Matrix{Float64}:0.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.04.44089e-160.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.04.44089e-160.00.02.66454e-150.00.00.00.00.00.00.00.00.00.0
julia>@timetest_hessian(ComplexF64)
0.000556 seconds (1.97 k allocations:1.387 MiB)
18×18 Matrix{Float64}:0.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0-6.93889e-180.00.00.00.0-1.38778e-160.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.04.33681e-190.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.01.38778e-170.00.00.00.02.77556e-170.00.00.00.00.00.00.00.00.00.00.00.0

On this PR I get without GenericLinearAlgebra:

julia>@timetest_hessian(Float64)
0.000071 seconds (215 allocations:55.547 KiB)
9×9 Matrix{Float64}:0.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.02.77556e-170.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.02.77556e-170.00.00.0-2.77556e-170.00.00.00.00.00.00.02.77556e-170.0
julia>@timetest_hessian(ComplexF64)
0.000491 seconds (1.00 k allocations:477.500 KiB)
18×18 Matrix{Float64}:0.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.06.93889e-180.00.00.00.00.0-1.38778e-170.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.0-6.93889e-180.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.02.77556e-170.00.00.00.00.00.00.00.00.00.00.00.00.0

Some benchmarks against master:

master

julia>using ForwardDiff, Chairmarks, LinearAlgebra
## Symmetric{<:Real}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(Symmetric(x * x'))), _)
11.312 μs (110 allocs:49.297 KiB)
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(Symmetric(x * x')).values), _)
29.083 μs (273 allocs:115.469 KiB)
## Hermitian{<:Complex}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(Hermitian(complex.(x * x', x'* x)))), _)
71.625 μs (4136 allocs:542.594 KiB)
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(Hermitian(complex.(x * x', x'* x))).values), _)
ERROR: MethodError: no method matching eigen!(::Hermitian{Complex{ForwardDiff.Dual{…}}, Matrix{Complex{…}}}; sortby::Nothing)
...## SymTridiagonal{<:Real}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(SymTridiagonal(x, x[begin:(end-1)]))), _)
14.500 μs (128 allocs:37.625 KiB)
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(SymTridiagonal(x, x[begin:(end-1)])).values), _)
32.625 μs (314 allocs:101.422 KiB)
## Hermitian{<:Real}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(Hermitian(x * x'))), _)
ERROR: MethodError: no method matching eigvals!(::Hermitian{ForwardDiff.Dual{ForwardDiff.Tag{…}, Float64, 10}, Matrix{ForwardDiff.Dual{…}}}; sortby::Nothing)
The function`eigvals!` exists, but no method is defined for this combination of argument types.
...
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(Hermitian(x * x')).values), _)
ERROR: MethodError: no method matching eigen!(::Hermitian{ForwardDiff.Dual{ForwardDiff.Tag{…}, Float64, 10}, Matrix{ForwardDiff.Dual{…}}}; sortby::Nothing)
The function`eigen!` exists, but no method is defined for this combination of argument types.
...

This PR

julia>using ForwardDiff, Chairmarks, LinearAlgebra
## Symmetric{<:Real}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(Symmetric(x * x'))), _)
15.834 μs (110 allocs:49.297 KiB)
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(Symmetric(x * x')).values), _)
19.500 μs (133 allocs:67.594 KiB)
## Hermitian{<:Complex}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(Hermitian(complex.(x * x', x'* x)))), _)
32.625 μs (136 allocs:98.062 KiB)
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(Hermitian(complex.(x * x', x'* x))).values), _)
39.875 μs (159 allocs:132.047 KiB)
## Symtridiagonal{<:Real}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(SymTridiagonal(x, x[begin:(end-1)]))), _)
10.938 μs (148 allocs:31.062 KiB)
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(SymTridiagonal(x, x[begin:(end-1)])).values), _)
14.125 μs (171 allocs:49.359 KiB)
## Hermitian{<:Real}
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigvals(Hermitian(x * x'))), _)
15.917 μs (110 allocs:49.297 KiB)
julia>@brand(10) ForwardDiff.gradient(x ->sum(eigen(Hermitian(x * x')).values), _)
19.250 μs (133 allocs:67.594 KiB)

@araujoms

araujoms commented Jul 25, 2025

Copy link
Copy Markdown

Amazing. I can confirm it fixes not only the MWE I posted, but also the real code I extracted it from.

Note that there is still a regression if the matrix is not wrapped in Hermitian, that is, if we change the function to

functionbarrier(point)
d2 =div(d, 2)
M =mat(point, T)
M[1:d2, d2+1:end] .=0
M[d2+1:end, 1:d2] .=0returnsum(real(eigvals(M)))
end

It doesn't matter to me, I never need a non-Hermitian matrix, I'm remarking just in case you wanted to know.

Comment threadtest/JacobianTest.jl
Comment on lines +266 to +271
# analytic solutions
@test ForwardDiff.jacobian(eigvals_symreal, x) ≈ [0 0; 2 4]
@test ForwardDiff.jacobian(eigvals_hermreal, x) ≈ [0 0; 2 4]
if !(x isa StaticArrays.StaticArray)
@test ForwardDiff.jacobian(eigvals_symtridiag, x) ≈ [(1 - 3/sqrt(5))/2 (1 - 1/sqrt(5))/2 ; (1 + 3/sqrt(5))/2 (1 + 1/sqrt(5))/2]
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't looked very closely, but are there more tests somewhere that check the values?

In what's changed in this PR, I see one more line with Calculus.finite_difference_jacobian(ev, float.(x0)) where I think x0 = [1.0, 2.0].

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there are more in the lines below. Much more exhaustive than on master.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry that wasn't clear. I see the many tests of different paths against each other, which is good.

But what I meant is tests of any ForwardDiff path against something completely independent -- a known answer, or finite differences. To catch things like making the same logic error in writing both _eigvals_hermitian and _eigen_hermitian here.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the tests below, Jacobians of every test function with ForwardDiff are checked against finite differencing based Jacobians. The latter doesn't involve any ForwardDiff paths.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry to be dense but where are you looking? Besides line 293, as mentioned:

I see one more line with Calculus.finite_difference_jacobian(ev, float.(x0)) where I think x0 = [1.0, 2.0].

@devmotiondevmotionJul 26, 2025

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is line 293. Calculus doesn't support all vector types, hence the ForwardDiff results with x (the possibly static version of x0: lines 259 and 262) is compared with the finite differencing result using the regular array x0.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok.

So the matrices being tested are these. Does it seem OK to test e.g. no negative eigenvalue for Symmetric{Real}, no zero eigenvalues for Hermitian{Complex}? Haven't thought hard but it just seemed a small sample.

julia> x
2-element Vector{Float64}:1.02.0
julia>Symmetric(x*x') # also wrapped in Hermitian2×2 Symmetric{Float64, Matrix{Float64}}:1.02.02.04.0
julia>eigvals(ans)
2-element Vector{Float64}:0.05.0
julia>Hermitian(complex.(x*x', x'*x))
2×2 Hermitian{ComplexF64, Matrix{ComplexF64}}:1.0+0.0im2.0+5.0im2.0-5.0im4.0+0.0im
julia>eigvals(ans)
2-element Vector{Float64}:-3.09016994374947548.090169943749475

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(That said, when I tried various tests locally, I did not manage to break this.)

@devmotion

Copy link
Copy Markdown
MemberAuthor

The PR fixes #780 as well: #780 (comment)

Comment threadtest/JacobianTest.jl
devmotionand others added 5 commits October 6, 2025 22:25
# Conflicts:
#	src/dual.jl
The matrices in the `eigen` testset are all of the form `x*x'` and hence
symmetric, so `:U` and `:L` wrap the same matrix and the `uplo` handling
these methods do is never exercised.
Add a case whose raw storage is deliberately not symmetric. On master
these fail: `eigvals(Symmetric(A, :L))` reads the values from the upper
triangle -- `Symmetric(value.(parent(A)))` drops the `uplo` -- while
reading the partials through the wrapper, so the two come from different
matrices and the Jacobian is off by 0.198.
The eigenvector case needs a matrix whose eigenvector direction actually
moves: for `[x1 x2; x2 x1+x2]`, `tan(2θ) = 2x2/(a-d) = -2` is constant,
so the derivative is identically zero and the test would pass either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`(Q' * ∂A) * Q` starts with `Adjoint * Symmetric`, which has no BLAS
specialization: it falls back to the generic `*(::AbstractMatrix,
::AbstractMatrix)`, allocating an extra n^2 temporary per partial and
skipping `symm`/`hemm` entirely (`mul!(C, Q', Symmetric(M))` is 1003 ns
against 169 ns for `mul!(C, Q', M)` at n = 10).
`∂A * Q` is `Symmetric * Matrix`, which does dispatch to `symm`, and the
remaining `Q' * _` is `Adjoint * Matrix`, i.e. `gemm`. Same result, one
fewer temporary, and the halved flop count now actually materializes.
This removes the one regression against master -- `eigvals(Symmetric)`
was 11.67 μs / 127 allocs / 58.5 KiB against master's 10.62 μs / 107 /
49.3 KiB, and is now 10.54 μs / 107 / 49.3 KiB -- and improves the rest
(gradient over a length-10 input):
master this branch
eigvals Symmetric 10.62 μs 49.3 KiB 10.54 μs 49.3 KiB
eigen Symmetric 25.54 μs 115.4 KiB 13.58 μs 68.6 KiB
eigvals Hermitian{C} 51.58 μs 544.8 KiB 25.75 μs 100.3 KiB
eigen SymTridiagonal 26.38 μs 89.4 KiB 14.00 μs 50.3 KiB
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion

Copy link
Copy Markdown
MemberAuthor

Refreshed this branch: merged current master, added a regression test for the uplo handling, and fixed the one performance regression it had against master.

Merged master

The base was ~10 months old. One trivial conflict: master inserted the LinearAlgebra.givensAlgorithm section (#783) immediately above the eigen section header this PR renames. The full suite passes locally; CI on this push will confirm.

uplo = :L was fixed here but never tested

Every matrix in the eigen testset is of the form x*x' and hence symmetric, so :U and :L wrap the same matrix and the uplo handling in _structured_value/_structured_partials is never exercised. Added a case whose raw storage is deliberately not symmetric.

On master those fail, because Symmetric(value.(parent(A))) drops the uplo and so reads the values from the upper triangle while the partials are read through the wrapper and honour the actual one:

julia> M = [1.02.0; 7.04.0]; Ad =Dual{TestTag}.(M, [1.00.0; 0.00.0]);
julia> ForwardDiff.value.(eigvals(Symmetric(Ad, :L))) # master2-element Vector{Float64}:0.05.0
julia>eigvals(Symmetric(M, :L)) # what it should be2-element Vector{Float64}:-4.6589105316381769.658910531638175

The Jacobian is off by 0.198 there; this branch matches finite differences to 1e-11 for Symmetric{<:Real}, Hermitian{<:Real} and Hermitian{<:Complex}, in both :U and :L, for eigvals, eigen().values and eigen().vectors.

One wrinkle worth recording: the eigenvector part of that test needs a matrix whose eigenvector direction actually depends on x. For [x1 x2; x2 x1+x2], tan(2θ) = 2x₂/(a-d) = -2 is constant, so the eigenvectors don't move, the derivative is identically zero, and the test would pass with or without the fix.

Associating Q' * (∂A * Q) instead of (Q' * ∂A) * Q

Benchmarking against current master showed eigvals(Symmetric) had got slower — 11.67 μs / 127 allocs / 58.5 KiB against master's 10.62 μs / 107 allocs / 49.3 KiB — which was odd given eigen(Symmetric) got much faster.

The cause is that (Q' * ∂A) * Q starts with Adjoint * Symmetric, which has no BLAS specialization. It falls back to the generic *(::AbstractMatrix, ::AbstractMatrix), which allocates an extra n² temporary per partial direction and skips symm/hemm:

Q' * M 178.7 ns 2 allocs 944 bytes # BLAS gemm
Q' * Symmetric(M) 273.5 ns 4 allocs 1.844 KiB # generic fallback
mul!(C, Q', M) 168.8 ns 0 allocs
mul!(C, Q', Symmetric(M)) 1003.0 ns 2 allocs # 6x slower

Re-associating fixes it without giving up the structure: ∂A * Q is Symmetric * Matrix, which does dispatch to symm, and the remaining Q' * _ is Adjoint * Matrix, i.e. gemm. Same result, one temporary fewer, and the halved flop count now actually materializes — at n = 100 the structured form finally beats a dense ∂A (32.3 μs vs 52.0 μs), which it did not before (49.5 μs).

SymTridiagonal keeps its wrapper either way and also benefits (23.3 μs vs 31.7 μs at n = 100); there the structure makes the first product O(n²), so it is well worth preserving.

Benchmarks against current master

ForwardDiff.gradient(f, rand(10)), minimum of @b:

masterthis branch
eigvals(Symmetric{<:Real})10.62 μs, 107 allocs, 49.27 KiB10.54 μs, 107 allocs, 49.27 KiB
eigen(Symmetric{<:Real}).values25.54 μs, 267 allocs, 115.41 KiB13.58 μs, 130 allocs, 68.56 KiB
eigvals(Hermitian{<:Real})MethodError10.83 μs, 107 allocs, 49.27 KiB
eigen(Hermitian{<:Real}).valuesMethodError13.58 μs, 130 allocs, 68.56 KiB
eigvals(Hermitian{<:Complex})51.58 μs, 4133 allocs, 544.84 KiB25.75 μs, 133 allocs, 100.31 KiB
eigen(Hermitian{<:Complex}).valuesMethodError33.38 μs, 156 allocs, 136.80 KiB
eigvals(SymTridiagonal)11.23 μs, 163 allocs, 31.64 KiB10.56 μs, 143 allocs, 31.02 KiB
eigen(SymTridiagonal).values26.38 μs, 380 allocs, 89.41 KiB14.00 μs, 166 allocs, 50.31 KiB

No regressions left; the numbers in the PR description above predate current master and this change.

Two things deliberately not changed

  • _to_duals's complex branch computes tuple.(parts...) twice, once for the real and once for the imaginary part. Hoisting it into a local looks like an obvious win but is a pessimization: inside the fused broadcast the tuples are never materialized, so hoisting forces an n²-element array of N-tuples into existence — 448 KiB against 240 KiB at n = 40, N = 8, to buy ~8% time. Left as is.
  • The repeated-eigenvalue case still divides by a vanishing eigenvalue gap and yields NaN/Inf eigenvector derivatives, exactly as on master (and as in ChainRules, which tracks it as an open TODO). That is Differentiate general eigen and eigvals #788's territory, not this PR's.

@codecov

codecovBot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.23%. Comparing base (569af35) to head (44813de).

Additional details and impacted files
@@ Coverage Diff @@## master #757 +/- ##
==========================================
+ Coverage 90.68% 91.23% +0.54% 
==========================================
Files 11 11 Lines 1052 1072 +20 ==========================================
+ Hits 954 978 +24 + Misses 98 94 -4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression in 1.0, asking for Hessian of a complex matrix function results in infinite loop

3 participants

@devmotion@araujoms@mcabbott