Uh oh!
There was an error while loading. Please reload this page.
Fix GMRES restart residual reconstruction - #586
Conversation
7894c26 to
fd9a7f9Comparepo-nuvai
commented
Aug 14, 2026
Reviewed this change. The math is right — the old restart kept only the One thing I caught while going back over it, and have now fixed in the branch: the reconstruction has to happen before the solution update, not after. With a preconditioner active the update reuses Non-blocking nits, if you want them:
Numerically I replicated the algorithm and it matches scipy's restarted GMRES to ~1e-10 with and without a right preconditioner (the old code sat at 1e-1 … 1e-6 and stagnated/breakdown'd). Default config is unaffected since |
FreddieWitherden
commented
Aug 14, 2026
Overall looks good. Can probably clean up the z assignments a bit z[i:i+1] = and see if it is easier to enumerate(zip(...)) over the c and s terms. |
b0eb448 to
80463c5Comparepo-nuvai
commented
Aug 14, 2026
Done — switched the cascade to slice assignment and fork, (c, s) inenumerate(zip(self._cs[j::-1], self._sn[j::-1])):
i=j-kz[i:i+2] = (c*z[i] -s*z[i+1], s*z[i] +c*z[i+1])Reads a bit cleaner, and it's still the same backward cascade (applies |
FreddieWitherden
commented
Aug 14, 2026
I think it ends up a bit uglier in practice due to the extra line breaks. Maybe just revert and just tweak the assignment. |
| # preconditioner is active. The residual is | ||
| # r_m = beta[j+1]*V_{j+2}*(G^T e_{j+2}), i.e. a combination of all | ||
| # of the Arnoldi vectors v_0..v_{j+1}; stash the result in v[j+1], | ||
| # which survives the solution update. |
There was a problem hiding this comment.
Aim for single line comments.
80463c5 to
16845d1CompareThe restarted-GMRES restart was reconstructing the residual as being
parallel to the last Arnoldi vector only, dropping the components in the
earlier Krylov directions. The correct residual is a combination of all
the Arnoldi vectors, obtained by cascading the Givens rotations back
through the basis (r_m = beta[j+1]*V_{j+2}*(G^T e_{j+2})). The old form
had the correct magnitude but wrong direction, causing restarted GMRES
(restart < linear-max-iter) to stagnate or break down instead of
converging.16845d1 to
9f85c5aCompareAgreed on both — reverted the foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i:i+2] = (c*z[i] -s*z[i+1], s*z[i] +c*z[i+1])Also condensed that comment block down to a single line. :) |
| z[j + 1] = 1.0 | ||
| for i in range(j, -1, -1): | ||
| c, s = self._cs[i], self._sn[i] | ||
| z[i:i + 2] = (c*z[i] - s*z[i + 1], s*z[i] + c*z[i + 1]) |
| for i in range(j, -1, -1): | ||
| c, s = self._cs[i], self._sn[i] | ||
| z[i:i + 2] = (c*z[i] - s*z[i + 1], s*z[i] + c*z[i + 1]) | ||
| z *= np.copysign(1.0, self._beta[j + 1]) |
There was a problem hiding this comment.
Do we need copysign here or will np.sign work?
| y = np.linalg.solve(self._H[:j + 1, :j + 1], self._beta[:j + 1]) | ||
| # Determine if a restart is needed after this cycle | ||
| will_restart = not (err < rtol or h_jp1_j < self._breakdown_tol |
There was a problem hiding this comment.
Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.
FreddieWitherden
commented
Aug 28, 2026
Are you okay if I get this upstream w/corrections? |
Summary
Fixes the restarted-GMRES residual reconstruction in
pyfr/integrators/implicit/krylov/gmres.py. Closes#585.The restart was reconstructing the residual as parallel to the last Arnoldi vector only, which has the correct magnitude but the wrong direction. In exact arithmetic the residual after a GMRES cycle is
i.e. a linear combination of all the Arnoldi vectors
v_0 … v_m, with coefficients cascading through every Givens rotation — not justv_m. Dropping the earlier components caused restarted GMRES (restart < linear-max-iter) to stagnate or break down instead of converging.The fix
Cascade the Givens rotations back through the Krylov basis to reconstruct the full residual direction. It reuses the already-stored
cs/snrotations and Krylov vectors, so it costs no extra matvec:The one subtlety worth calling out: the reconstruction has to happen before the solution update, not after. The preconditioned solution update reuses
v[0]as a scratch register forM⁻¹(V y), which would destroy the first Arnoldi vector that the cascade needs as a source.Validation
Replicating the algorithm in NumPy (MGS/CGS Arnoldi + incremental Givens) and comparing against
scipy.sparse.linalg.gmreson a well-conditioned SPD matrix (cond ≈ 8), targetingrtol = 1e-10, with and without a right preconditioner:mNotes
solver-gmres restartdefaults to0(single cycle), so the buggy path only triggers whenrestart < linear-max-iter.cgsvsmgs) and of preconditioning.