Fix checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg
, '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 checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg
, '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 checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg
, '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 checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg
, '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 checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg
, '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 checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg
, '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 checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg
, '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 checkpointed recompute aliasing for state-transfer Function targets - #76

Open
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity
Open

Fix checkpointed recompute aliasing for state-transfer Function targets#76
finsberg wants to merge 5 commits into
checkpointingfrom
finsberg/dirichlet-bc-recompute-identity

Conversation

@finsberg

@finsbergfinsberg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

FunctionAssignBlock.recompute_component always mutated block_variable.saved_output in place and returned the same object on every recompute. That's required for a Function backing a live dolfinx.fem.DirichletBC (the C++ binding reads that object's array directly, not through the tape), but wrong for an ordinary state-transfer Function (e.g. assign(uh, u_prev) in a time-stepping loop): every timestep's recompute silently overwrote the value an earlier timestep's checkpoint relied on, giving a wrong gradient once a checkpoint schedule actually forces replay (confirmed empirically: Taylor rate 0.70 instead of 2.0 under Revolve, exact 2.0 with no schedule).

This PR went through two designs before landing on the current one — recorded here since both are informative:

  1. (shipped first, since replaced) Tag a Function as identity-sensitive via a new Function._ad_bc_backing attribute, set by DirichletBC.__init__, and branch FunctionAssignBlock.recompute_component on it. Worked, but left an implicit, easy-to-forget special case: a boolean flag that silently changes what a generic assign() call does, set from a completely different call site. It also had a documented residual gap — a Function that was both BC-backing and reassigned every timestep still hit the original aliasing bug.
  2. Shipped:FunctionAssignBlock isolates unconditionally for every Function target — no tag, no branch. LinearProblemBlock/NonlinearProblemBlock instead track each BC's backing Function (bc.g) as an explicit tape dependency, the same way every other form coefficient already is, and a new sync_bc_values helper (blocks/dirichletbc.py) refreshes bc.g's live array from that pinned dependency's saved_output right before every solve, including during recompute. This closes the residual gap design 1 had to leave open, and needed no new attribute anywhere.

A first attempt at sync_bc_values, keyed off bc.g.block_variable.saved_output directly, was also tried and also empirically wrong — .block_variable always points at bc.g's most recently created BlockVariable, which after the full tape is recorded is simply the last timestep's, regardless of which point in a replay is being recomputed. Reading from the calling block's own pinned dependency (self.get_dependencies()) instead is what's actually position-aware. Recorded in the spec so nobody rediscovers this by bisection.

No change to assign()'s or dirichletbc()'s public signature. The PR #75RuntimeError guard is untouched. One unrelated cleanup: removed a now-stale xfail(strict=True) on test_snes_time_loop_gradient_is_correct, which started passing due to an earlier, separate fix and was blocking a fully green test_checkpointing.py.

Full design rationale, including the rejected designs and the empirical evidence for each, in .scratch/dirichlet-bc-recompute-identity/spec.md (knowledge repo).

Test plan

  • tests/test_checkpointing.py::test_bc_gradient_matches_uncheckpointed (new) — a time-dependent Dirichlet BC under a genuine Revolve schedule, comparing gradients (not just forward values) against the unscheduled run. Fails on the previously-shipped tag-based design with a real ~0.6% gradient mismatch (test_time_dependent_bc_replay never enables an actual schedule, so it couldn't catch this); passes exactly on the current design.
  • tests/test_assign.py::test_recompute_does_not_alias_state_across_timesteps — reproduces the original state-transfer aliasing defect directly via assign() chains under a Revolve schedule, no PDE solve
  • tests/test_dirichlet_bc.py::test_time_dependent_bc_replay — stays green throughout (the test that would catch a regression toward "always isolate")
  • tests/test_checkpointing.py — all previously-red tests pass (test_gradient_matches_uncheckpointed, test_taylor_test_under_checkpointing, test_disk_gradient_matches_uncheckpointed, test_disk_taylor_test)
  • Full suite: 86 passed, 1 xfailed (pre-existing, unrelated residual-timestepping defect)
  • ruff check . and mypy src/dolfinx_adjoint clean

🤖 Generated with Claude Code

finsbergand others added 5 commits August 28, 2026 10:25
…argets
FunctionAssignBlock.recompute_component mutated block_variable.saved_output
in place on every recompute. This is required for _ad_bc_backing-tagged
Functions (a live DirichletBC reads that exact object's array via a C++
binding, not through the tape) but silently aliases state for ordinary
Function targets reused across a time loop (e.g. a "previous timestep
value"): once a checkpoint schedule forces genuine recompute, each
timestep's recompute overwrites the value an earlier timestep's checkpoint
was relying on.
Return an isolated snapshot (via Function._ad_new_like()) for any Function
target that is not backing a live DirichletBC, and keep the in-place update
for DirichletBC-backing Functions and non-Function outputs.
Also restores the working tape at the end of the new
test_recompute_does_not_alias_state_across_timesteps test: a tape that has
had checkpointing enabled keeps eagerly checkpointing outputs even after
clear_tape() (per the isolated_tape fixture in test_checkpointing.py), so
leaving the Revolve-enabled tape as the global working tape broke
test_time_dependent_bc_replay when the test files ran in the same session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correct
The test now passes due to an unrelated SNES coefficient-replacement fix
that landed via a merge. The underlying defect is fixed, so retire the xfail marker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow the isinstance check in FunctionAssignBlock.recompute_component to
the overloaded _Function type, matching the type that actually declares
_ad_bc_backing, and simplify the accompanying comment to drop a vacuous
"non-Function output" clause.
- Add the missing clear_tape() to test_dirichletbc_tags_its_value_function
in tests/test_dirichlet_bc.py, matching the file's convention, after the
final review confirmed its absence leaks a block onto the shared tape.
- Remove an unused Function/interpolate() pair in
test_recompute_does_not_alias_state_across_timesteps
(tests/test_assign.py); the test's actual controls come from a separate
list.
- Document, in the _ad_bc_backing docstring, that tagging trades away
checkpoint-aliasing safety for BC identity, so a Function needing both is
unsupported.
FunctionAssignBlock now isolates unconditionally for every Function target,
with no special case. Instead, LinearProblemBlock and NonlinearProblemBlock
track each BC's backing Function (bc.g) as an explicit dependency, the same
way every other form coefficient already is, and sync_bc_values refreshes
bc.g's live array from that pinned dependency's own saved_output right
before each solve -- including during recompute.
An earlier version of this fix (and, before that, a version using
bc.g.block_variable.saved_output directly) both looked plausible but were
empirically wrong: bc.g.block_variable always points at bc.g's most
recently created BlockVariable, which after the tape is fully recorded is
simply the last timestep's, regardless of which point in a replay is being
recomputed. Reading from the calling block's own pinned dependency instead
is what's actually position-aware.
Adds test_bc_gradient_matches_uncheckpointed, which enables a genuine
Revolve schedule (unlike test_time_dependent_bc_replay, which only ever
does a full unscheduled replay) and would have caught this: it fails on
the previously-shipped tag-based version with a real ~0.6% gradient
mismatch, and passes exactly on this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant

@finsberg