Fix overflow in the eigh_trunc_pullback! Sylvester iteration - #282
Conversation
|
I ran into this issue while trying to properly benchmark QuantumKitHub/PEPSKit.jl#430. |
Jutho
left a comment
There was a problem hiding this comment.
Nice catch. Thanks for the fix Lander & 🤖 .
| # The Sylvester doubling iteration in `eigh_trunc_pullback!` squares `APₖ` and `D⁻¹ₖ` | ||
| # separately although only their product is used. The product is bounded by | ||
| # ρ = |λ_discarded|max / |λ_retained|min < 1, but the factors are not, so if the series | ||
| # needs more doublings to converge than it takes them to leave the floating-point range, | ||
| # `APₖ` underflows to 0 while `D⁻¹ₖ` overflows to Inf and the next iterate is NaN. | ||
| # | ||
| # That needs ρ close to 1 — a well-separated truncation converges in a few doublings — so | ||
| # the spectrum below is chosen with | ||
| # | ||
| # |λ|max / |λ_kept|min = 30 -> D⁻¹^(2^k) overflows Float64 at 2^k > 208 (k = 8) | ||
| # ρ = 0.95 -> ρ^m < 1e-13 needs m > 583 (k = 10) | ||
|
|
There was a problem hiding this comment.
Can we rewrite this into a small comment instead?
There was a problem hiding this comment.
I fought a bit to get the diff and description compact, but looks like I missed this one. I'll compress it.
There was a problem hiding this comment.
I cut it down to the most compact I could get it while still describing the original problem in full. I thought this could be useful, but I can also just say "Regression test for over/underflow issues in eigh_trunc_pullback!" if that's better.
There was a problem hiding this comment.
Is there a way to add this to the existing tests for mooncake/enzyme instead, or do we really want to start testing our pullbacks directly? I'm not necessarily convinced for either way, just wanted to bring this up for discussion briefly
There was a problem hiding this comment.
Either is fine for me, I didn't really think about where exactly I was adding this in comparison to the other pullback tests.
Co-authored-by: Jutho <Jutho@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests.
🚀 New features to boost your workflow:
|
eigh_trunc_pullback!returnsNaNfor a class of well-conditioned inputs.eigh_trunc_pullback!andsvd_trunc_pullback!sum the same Neumann series by the samedoubling iteration, but they normalize it differently:
Sis sorted descending, soS[end]is the smallest retained singular value andmax|S⁻¹| = 1exactly — squaring can only shrink it.eighscales by the largest |λ|instead, leaving
max|D⁻¹| = |λ|max / |λ_retained|min > 1, which squares its way toInfwhile
APₖunderflows to0. The next iterate is0 * Inf = NaN.The failure needs the truncation to be marginal —
ρ = |λ_disc|max / |λ_kept|minclose to1 — so that the series needs more doublings than the factors survive. A clean truncation gap
converges in two or three doublings, long before anything leaves range, which is why CI does
not catch it.
The failing input
A 270×270 block from a CTMRG gradient (PEPSKit.jl, C4v enlarged corner, D=3, χ=30). Exactly
Hermitian,
‖A‖ = 1.000000,Visometric to 1.5e-13:22.15^(2^k)passes1.8e308at k=8, whileρ = 0.915needs 338 terms — 9 doublings — toreach
1e-13. One doubling short. (The ± pairs are a consequence of the C4v symmetry; theoverflow depends only on
|λ|.)It does not throw. It warns (
Sylvester iteration did not converge ... final norm of X: NaN) and returns theNaN, which surfaces far from the cause — for us asArgumentError: cannot set off-diagonal entry (2, 1) to a nonzero value.Self-contained reproducer — no data files, runs against this branch and against
mainNeeds only
MatrixAlgebraKit,StableRNGsand stdlibs. Callseigh_trunc_pullback!, thenruns the loop transcribed with the normalizer as a flag so the two choices can be compared.
Output on
main:Output on this branch:
max|Dinv_k|is pinned at exactly 1 in the second run, and theproductcolumn — thequantity that actually governs convergence — is identical in both at every
kwhere thefirst still has numbers. The change is a rescaling, not a change of algorithm.
Changes
1. Normalize by the smallest retained |eigenvalue|, matching
svd_trunc_pullback!:2.
β = 0in the final assembly. This is a separate, pre-existing bug:APₖ₊₁aliases theAPbuffer (APₖ, APₖ₊₁ = AP * AP, AP), so the squaring loop writesthrough it and
β = 1adds whatever it last held into the result.This is already wrong on
main, independently of change 1. When the truncation is wellseparated the loop converges at
k = 1, so no squaring has been written yet and the bufferstill holds the original
AP— of size|λ_disc|max / |λ|max, not zero. The resulting errortracks exactly that ratio (error vs the full pullback,
main, retained spectrum[±30, ±5, ±1]):|λ_disc|max / |λ|maxmainIt stays hidden in the cases that run many doublings only because the old normalizer squares
APto exactly0.0there. Change 1 sets‖AP‖ = ρ, soAPₖno longer vanishes and theterm would become visible everywhere: with change 1 alone the three spectra below give
errors of 3.1e-9, 1.5e-15 and 1.5e-7.
Verification
Against the full untruncated pullback on the 270×270 block (
eigh_pullback!withVfull = [V, W*Q],Dfull = [D; Λ], zero-padded cotangents; reconstruction error 8.2e-15):Hermiticity of the result is exactly 0. Same comparison on three synthetic spectra,
confirming no regression where the iteration already worked:
The new
test/common/eigh_trunc_pullback_overflow.jlcovers both failure modes against thefull pullback, for
Float64andComplexF64, and fails onmainin both:|λ|max/|λ_kept|min = 30,ρ = 0.95) —mainreturnsNaN;k = 1(ρ = 1e-6) —mainis off by 1.6e-08.Not addressed here
eigh.jlandsvd.jlexit onnorm(Xₖ₊₁, Inf) < degeneracy_atol, using a parameternamed for degeneracy detection as an absolute tolerance on the increment.
TODOonmaxiterlooks tractable now: with this normalizationρis exactlymax|AP₁| · max|D⁻¹₁|, so the required doublings can be estimated up front.ρ ≥ 1still diverges, and still reports it only as a warning plus aNaNreturn.