Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Fix GMRES restart residual reconstruction - #586

Open
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart
Open

Fix GMRES restart residual reconstruction#586
po-nuvai wants to merge 1 commit into
PyFR:developfrom
po-nuvai:fix/gmres-restart

Conversation

@po-nuvai

@po-nuvaipo-nuvai commented Aug 14, 2026

Copy link
Copy Markdown

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

r_m = β_m · V_{m+1} · (G₀ᵀ G₁ᵀ … G_{m-1}ᵀ e_{m+1})

i.e. a linear combination of all the Arnoldi vectors v_0 … v_m, with coefficients cascading through every Givens rotation — not just v_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/sn rotations and Krylov vectors, so it costs no extra matvec:

# reconstruct the residual direction *before* the solution update, because# the preconditioned update reuses v[0] as scratch for M⁻¹(V y) and would# otherwise clobber it; stash the result in v[j+1], which survives.ifwill_restart:
self._add(1/h_jp1_j, v[j+1]) # normalise the last Arnoldi vectorz=np.zeros(j+2)
z[j+1] =1.0foriinrange(j, -1, -1):
c, s=self._cs[i], self._sn[i]
z[i], z[i+1] =c*z[i] -s*z[i+1], s*z[i] +c*z[i+1]
z*=np.copysign(1.0, self._beta[j+1])
self._addv([z[j+1], *z[:j+1].tolist()], [v[j+1], *v[:j+1]])
# ... solution update ...ifnotwill_restart:
breakrnorm=abs(self._beta[j+1])
self._add(0, v[0], 1, v[j+1]) # install the reconstructed residual

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 for M⁻¹(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.gmres on a well-conditioned SPD matrix (cond ≈ 8), targeting rtol = 1e-10, with and without a right preconditioner:

restart moldfix (no precond)fix (preconditioned)scipy
21.3e-019.2e-115.0e-119.2e-11
43.3e-026.5e-113.6e-116.5e-11
81.0e-038.8e-115.2e-118.8e-11
153.9e-061.0e-109.8e-111.0e-10

Notes

  • Masked by defaultsolver-gmres restart defaults to 0 (single cycle), so the buggy path only triggers when restart < linear-max-iter.
  • Independent of the Arnoldi variant (cgs vs mgs) and of preconditioning.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from 7894c26 to fd9a7f9CompareAugust 14, 2026 07:12
@po-nuvai

Copy link
Copy Markdown
Author

Reviewed this change. The math is right — the old restart kept only the v[j+1] component of the residual (correct magnitude, wrong direction), and cascading the Givens rotations back through the basis is the proper reconstruction of r_m = β_m V_{m+1} (Gᵀ e_{m+2}).

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 v[0] as scratch for M⁻¹(V y), which destroys the first Arnoldi vector that the cascade reads as a source. So the residual direction is now computed ahead of the update, stashed in v[j+1] (which survives), and installed into v[0] afterwards.

Non-blocking nits, if you want them:

  • the cascade tuple has redundant outer parens (z[i], z[i + 1] = (...)).
  • np.zeros(j + 2) could be dtype=self._beta.dtype for explicitness; it defaults to float64 which matches _cs/_sn/_beta anyway.

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 restart defaults to 0 → single cycle.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

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.

@po-nuvai
po-nuvaiforce-pushed the fix/gmres-restart branch 2 times, most recently from b0eb448 to 80463c5CompareAugust 14, 2026 14:41
@po-nuvai

Copy link
Copy Markdown
Author

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Done — switched the cascade to slice assignment and enumerate(zip(...)) over the c/s pairs:

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 G_j^TG_0^T in order). I re-checked the equivalence numerically — identical to machine precision against the previous form.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aim for single line comments.

The 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.
@po-nuvai

po-nuvai commented Aug 14, 2026

Copy link
Copy Markdown
Author

Agreed on both — reverted the enumerate(zip(...)) (the line breaks made it worse) and just kept the slice-assignment tweak:

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can drop the ()

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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Given we not this in one if later on, we may want to remove the not and rename the variable accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done maybe?

@FreddieWitherden

Copy link
Copy Markdown
Contributor

Are you okay if I get this upstream w/corrections?

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.

GMRES restart reconstructs the residual incorrectly (missing Krylov components)

2 participants

@po-nuvai@FreddieWitherden