Skip to content

evd: nonsymmetric EVD fixes — ?GEEV input scaling, AED sort termination, ?LALN2 2x2 solves (#112, #144, #145) - #154

Merged
shinaoka merged 6 commits into
mainfrom
fix/nonsymmetric-evd
Oct 29, 2026
Merged

shinaoka merged 6 commits into
mainfrom
fix/nonsymmetric-evd

Conversation

@shinaoka

Copy link
Copy Markdown
Member

Closes #112. Closes #144. Closes #145.

This PR combines #148 (#112), #151 (#144) and #152 (#145), as the maintainer directed on 2026-10-29 ("関連する修正は単一のPRにまとめてもいいよ"). All three change the nonsymmetric EVD path and conflicted with each other. The original commits are kept with their authorship and messages; only the upstream report numbers are renumbered (faer-029, -030, -031), because all three had taken faer-029. Each fix's regression tests, PATCHSET row and upstream report are kept.

1. Input scaling as ?GEEV (#112, from #148)

Stage-by-stage comparison with cgeev (c32 kinds 6/7, n = 63–65):

stage as is scaled to unit size
Hessenberg backward error / eps 16–20 16–20
complex Schur backward error / eps 226–258 67–93

?GEEV scales its input into [smlnum, bignum] (smlnum = sqrt(safmin)/eps) before the reduction; evd_imp did not.

Where the gap grows. A 372-matrix sweep against LAPACK ?geev (scipy/OpenBLAS) covered f32/f64/c32/c64, n = 16–257, the random, Grcar, near-Jordan and graded families, and scales up to the range limits. On random, Grcar and near-Jordan matrices:

  • The GET22 ratio grew from about 2^-90 (32-bit).
  • At 2^-100 (32-bit) and 2^-960 (64-bit) and below, it was 1e4 to 2e15, against LAPACK's 2–70.
  • f32 also gave NoConvergence, and two inputs did not return.
  • faer 0.24.4 returns NaN/inf eigenvectors with Ok there.

Fix. evd_imp scales by a power of two to just inside the bound, and scales the eigenvalues back.

Tests.

  • geev_scaling_tests::evd_scales_its_input_into_range fails without the scaling (f32 2^-120: 1.7e6).
  • evd_scaling_at_the_complex_range_ends fails with a modulus-based scan.

The known-failures entry for #112 is removed.

Upstream report: faer-029-evd-input-not-scaled.

2. The real Schur deflation-window sort always ends (#144, from #151)

aggressive_early_deflation's bubble sort, ported from dlaqr3, had two faults:

  • It sorted the deflated part ns..jw instead of the undeflated part infqr..ns.
  • It set the next window from the last swap.

Since schur_swap reported success for a rejected 2×2 swap, the sort compared the same pair forever. Mat::eigen of f32 J_n + 1e-6 G at 2^-100 hung at n = 128 and 257.

Fix. The sort follows dlaqr3:

  • It sorts the undeflated part.
  • Each pass ends one before where the previous pass stopped, so the sort ends after at most ns passes.
  • It uses schur_swap_checked, and a rejected swap leaves the pair in place.

Test: aed_sort_terminates_when_a_swap_fails, run through hessenberg_schur_real (which, like ?HSEQR, does not scale its input) under a 120 s timeout. On main it times out at n = 128.

Upstream report: faer-030-real-schur-aed-sort-hang. Neither size returns within 60 s on 0.24.4.

3. The 2×2 block solves of the real eigenvector back substitution follow ?LALN2 (#145, from #152)

Stage-by-stage on f64 graded n = 64:

  • The Schur backward error is 8 eps|A|.
  • The eigenvectors of faer's own Schur form T have a residual of 3.4e7, where dtrevc3 on the same T gives 2.2e-4.
  • All 24 failing blocks are complex pairs.

solve_cplx_shifted_upper_quasi_triangular_system solved each shifted 2×2 block through its inverse, and compared the determinant (a product of two entries) with the entry-sized clamp. So every block with entries below sqrt(clamp) got a floored determinant with unchanged numerators.

Fix. Both 2×2 solves use ?LALN2's method:

  • an all-small block is taken as clamp * I;
  • otherwise, Gaussian elimination with complete pivoting, with the second pivot raised to the clamp.

Balancing (?GEBAL) is not the cause. Unbalanced dgeev gives 0.58 on the same matrix, and the fixed faer gives 0.45. Adopting balancing is a separate decision.

Tests:

  • lapack_nonsymmetric_evd_graded_pairs_keep_their_residual (f64 before: 5.9e8 at n = 16; 1.5e12 on a coupled 4×4);
  • ..._pair_solve_with_a_small_determinant;
  • ..._real_shift_block_below_the_square_root_of_min_positive (NoConvergence before).

Upstream report: faer-031-real-evd-pair-clamp. 0.24.4 gives 8.97e4 on a 4×4 where dgeev gives 9.6e-14.

Verification of the combined branch (primerose, 4 cores)

inputs before this branch LAPACK
real graded, n = 16–257 (D A D^-1, D = 1e±6) up to 2.6e9 0.20–0.87 below 5e-4 (balanced)
complex graded up to 1.2 0.19–1.19 below 5e-4
tiny (2^-100 32-bit, 2^-900 64-bit), all types and families up to 4e6, plus NoConvergence and hangs 2.4–78; at most 1.68× LAPACK 1.9–102
f32 near-Jordan n = 128 / 257 at 2^-100 (hung before) no return 42 / 60 44 / 69
  • Each fix had its own independent post-review (gpt-6.1-sol) on its original PR, and the findings were fixed there. The conflicts were only in the ledgers (PATCHSET rows, the upstream-reports table and report numbers); the code merged without a conflict.
  • Performance: not timed. The changes are one pass over the input, a sort bound, and a 2×2 solve.

LAPACK sources were read only; no code was copied. Nothing was posted upstream.

🤖 Generated with Claude Code

shinaoka and others added 6 commits October 29, 2026 21:05
evd_imp reduced and iterated on the matrix as given. ?GEEV first scales it
into [smlnum, bignum] (smlnum = sqrt(safmin) / eps). Without that, entries
below about 2^-90 (32-bit) or 2^-960 (64-bit) went through the Hessenberg
reduction and the Schur iteration in subnormals and the eigenvectors lost
every digit (GET22 1e5 to 2e15, LAPACK 2 to 70), and the complex Schur
iteration near the top of the range lost the factor that kept the c32
LAPACK group failing. The scaling is a power of two, so it is exact, and the
eigenvalues are scaled back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
faer-029 reproduces the lost eigenvectors of a tiny matrix on faer 0.24.4
(f64 2^-960: GET22 1.1e15; f32 2^-100: 1.8e6; LAPACK 16 and 16) and on the
fork prints 17.7 and 15.2 at every scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…view)

faer's complex modulus is inf for 2^1023 + 0i and zero for 2^-1074 + 0i, so a
modulus-based scan selected a zero factor (NaN eigenvalue with Ok) or no
scaling at all. The tests reject nonfinite results before f64::max can drop
them; the report notes that faer 0.24.4 returns nonfinite eigenvectors with
Ok at 2^-1000 (f64) and 2^-100 (f32); the docs restrict their claims to the
measured families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aggressive_early_deflation sorted the window with a bubble sort ported
from LAPACK dlaqr3, but over the deflated part (dlaqr3's initial
I = NS + 1 only sets the first pass's end) and with each next window
set from the last swap's position. schur_swap reports success even when
schur_swap_22 rejects an ill-conditioned swap, so the same pair was
compared forever: Mat::eigen of the f32 matrix J_n + 1e-6 G scaled to
2^-100 did not return at n = 128 and 257.

The sort now follows dlaqr3: it sorts the undeflated part infqr..ns,
ends each pass at kend, one before where the previous pass stopped, and
swaps with schur_swap_checked (the dlaexc ports for 1 x 2 and 2 x 1),
leaving a rejected pair in place. Upstream faer 0.24.4 has the same
code; the upstream report is faer-030.

Closes #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… follow ?LALN2 (#145)

The back substitution for a complex eigenvalue of a real Schur form solved
each 2 x 2 diagonal block through its inverse and compared the block's
determinant with the entry-sized clamp `eps_norm`. A determinant is a
product of two entries, so every block with entries below
`sqrt(eps_norm)` got the determinant `eps_norm` with its original
numerators. After `scaled_into_range` the blocks of a strongly graded
matrix are all that small, so every complex pair got a wrong eigenvector
(GET22 up to 2.6e9).

Stage comparison with LAPACK 3.12.0 on f64 n = 64: the Schur form is fine
(backward error 8.0); on that same Schur form `dtrevc3` gives 2.2e-4 and
faer 3.4e7, and every failing eigen-block is a complex pair. Balancing is
not the cause (`dgeev` without it: 0.58; fixed faer: 0.45).

Both 2 x 2 solves now use `?LALN2`'s method (`solve_shifted_2x2_cplx`):
`eps_norm * I` when every entry is below `eps_norm`, otherwise
elimination with complete pivoting and the second pivot raised to
`eps_norm`. That covers the real-shift solve too, whose inverse through
`a^2 - b c` underflowed to `inf` for tiny blocks. Raising only the
determinant was rejected in review: with the original numerators it is not
a solved system (4.5e14 on a 4 x 4 Schur form, now a test).

Upstream faer 0.24.4 has the same comparison; it is added to
upstream-reports as faer-031 with a 4 x 4 input that fails there and
passes with this solve.

Closes #145

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With #112's input scaling in the same change the c32 known failure is gone,
so the #145 row no longer reports it; it records the combined result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shinaoka
shinaoka force-pushed the fix/nonsymmetric-evd branch from f986ec7 to 3ea3887 Compare October 29, 2026 12:10
@shinaoka
shinaoka merged commit b903fa1 into main Oct 29, 2026
7 checks passed
@shinaoka
shinaoka deleted the fix/nonsymmetric-evd branch October 29, 2026 12:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant