Skip to content

Support checkpointing of time-dependent adjoints - #71

Open
finsberg wants to merge 19 commits into
mainfrom
checkpointing
Open

Support checkpointing of time-dependent adjoints#71
finsberg wants to merge 19 commits into
mainfrom
checkpointing

Conversation

@finsberg

@finsberg finsberg commented Aug 25, 2026

Copy link
Copy Markdown
Member

Adds DOLFINx-side support for checkpointing time-dependent adjoints. pyadjoint already drives checkpoint schedules from checkpoint_schedules; this fills in the missing half of that contract so long time loops can trade memory for recomputation instead of keeping every state alive for the adjoint sweep.

Usage is entirely through pyadjoint's own API — tape.enable_checkpointing(...) and tape.timestepper(...). The one new name is enable_disk_checkpointing, for schedules that spill to disk.

What changed

  • Solver blocks no longer leak a plain dolfinx.fem.Function onto the tape. Both LinearProblemBlock and NonlinearProblemBlock built their replay output as a non-overloaded Function; under a schedule that value gets re-stored on a later pass and checkpointing fails. Fixed in one shared helper.
  • New disk backend (h5py-based) for schedules that checkpoint to disk. These are run-local snapshot checkpoints, not portable artifacts (no mesh/partition data, unlike adios4dolfinx), and are deleted at teardown. Ghost values are stored alongside owned ones so restoring needs no collective communication.
  • use_mpio picks one shared MPI-IO file vs. one file per process.

Known limitation (pre-existing, not from this change)

NonlinearProblem can't yet be advanced over timesteps — its adjoint is already wrong on unmodified main with checkpointing disabled (Taylor rate -0.41 vs. 2.0). Recorded as a strict xfail.

Not in scope

Solver reuse across timesteps, and Hessians under checkpointing (blocked on an upstream pyadjoint change — evaluate_tlm/evaluate_hessian don't consult the checkpoint manager the way evaluate_adj does).

Verification

72 passed, 2 xfailed, serially and under mpirun -n 2. mypy/ruff clean. Tests check the checkpointed gradient against the un-checkpointed one and run a Taylor test, over both file layouts.

🤖 Generated with Claude Code

pyadjoint already drives checkpoint schedules from checkpoint_schedules;
what was missing was the DOLFINx side of the contract.

Blocks must not put non-overloaded values on the tape. Both solver blocks
built their replay solution vector as a plain dolfinx.fem.Function, which
became the block's output. Outside checkpointing nothing asks such a value
to checkpoint itself, so this went unnoticed; under a schedule a stored
output is re-stored on a later pass and it fails. Extracted the
construction so the rule lives in one place.

Added a disk backend for schedules that store on disk, written with h5py
rather than taking on adios4dolfinx: these are snapshot checkpoints, valid
only within the run that wrote them and against an unchanged partition, so
the payload is just a process's local values with no mesh or permutation
data. Ghost values are stored alongside the owned ones so that restoring
needs no communication -- restores are filtered by a cache whose lifetime
depends on when the garbage collector runs, which is not the same moment on
every process, and a collective call on that path deadlocks. Checkpoint
data stays in one file until teardown, because pyadjoint resets package
data before recomputing but then restores an initial condition written
while taping.

enable_disk_checkpointing is the only name this adds; schedules and the
timestepping loop stay pure pyadjoint.

Tests compare the checkpointed gradient against the un-checkpointed one and
run a Taylor test, over both file layouts, serially and on two processes.

NonlinearProblem cannot yet be advanced over timesteps: its adjoint is
wrong with checkpointing disabled too (Taylor rate -0.41 against 2 for the
linear model), which is pre-existing and unrelated. Recorded as a strict
xfail so it reports itself when fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread demos/time_distributed_control_checkpointing.py Outdated
The disk backend needs h5py, and the CI image does not ship it: both the
test module and the demo's disk section failed to import. Declared as a
dependency rather than an extra, since the demo exercises it on every docs
build. The lazy import stays as a safety net, and an h5py without MPI
support still works -- each process then writes its own checkpoint file.

Point the demo's API references at the packages they belong to via
intersphinx, and cite the checkpointing literature with a per-document key
prefix so the labels stay unique across pages.

Refer to io4dolfinx rather than its former name.

The demo now also turns disk checkpointing off when it is done, which is
what deletes the checkpoint files.
grad_ckpt = [np.copy(g.x.array) for g in rf_ckpt.derivative()]

assert np.isclose(J_plain, J_ckpt)
for a, e in zip(grad_ckpt, grad_plain):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Use strict in zip.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, in both places in the demo, and in the two equivalent zips in tests/test_checkpointing.py for consistency.

Comment thread demos/time_distributed_control_checkpointing.py Outdated
Comment thread demos/time_distributed_control_checkpointing.py Outdated
Comment on lines +141 to +147
directions = []
for k in range(num_steps):
h = dolfinx_adjoint.Function(V, name=f"direction_{k}")
# Interpolated rather than random: the direction has to be the same on every process, and
# per-process random numbers are not.
h.interpolate(lambda x, k=k: np.sin((k + 1) * np.pi * x[0]) * np.cos(np.pi * x[1]))
directions.append(h)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't annotate=False be set in initialization of these functions, or use with pyadjoint.pause_annotating():.... ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right, they were being taped. Wrapped in pyadjoint.stop_annotating() in both the demo and the test helper: the directions are inputs to the test, not part of the model.

Simplify the note on tape.timestepper, and say why Firedrake can pass a
bare range: it sets tape.progress_bar, whose iter() returns a real
iterator, while the default passes the argument straight through for
next() to choke on.

Use strict zips and np.testing.assert_allclose so a mismatch reports what
differed rather than just failing, and rewrite the sentence introducing the
Taylor test to say what it means.
Comment thread demos/time_distributed_control_checkpointing.py Outdated

assert np.isclose(J_plain, J_disk)
for a, e in zip(grad_disk, grad_plain):
assert np.allclose(a, e)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Strict in zip and assert allclose instead of allclose.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Both done in that block already: zip(..., strict=True) and np.testing.assert_allclose.

Comment thread src/dolfinx_adjoint/blocks/solvers.py Outdated
Overloaded rather than plain, because whatever the block returns from
`recompute_component` becomes its output on the tape: under a checkpoint schedule a stored
output is asked to checkpoint itself again on a later pass, which a plain
`dolfinx.fem.Function` cannot do. Outside checkpointing nothing asks, which is why a plain

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Use {py:class}dolfinx.fem.Function here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

Comment thread src/dolfinx_adjoint/blocks/solvers.py Outdated
"""
with stop_annotating():
if isinstance(u, dolfinx.fem.Function):
return Function(u.function_space, name=u.name + "_initial_guess")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Function is imported as _Function in line 10.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good spot — switched the isinstance check to _Function to match the annotation.

Comment thread src/dolfinx_adjoint/types/function.py Outdated

@no_annotations
def _ad_create_checkpoint(self):
from ..checkpointing import maybe_disk_checkpoint

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can import be moved to the top of the file? Is there any reason not to do it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved to module scope. It was local to avoid a cycle, but checkpointing.py only needs Function for annotations now (under typing.TYPE_CHECKING), so there is no runtime cycle left.

Comment thread src/dolfinx_adjoint/types/function.py Outdated
return checkpoint

def _ad_restore_at_checkpoint(self, checkpoint):
from ..checkpointing import SnapshotCheckpoint

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same comment regarding import. Why not at top of file?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same — moved to module scope.

zip(..., strict=True) needs 3.10, so declare the floor rather than leave it
implicit.

assign_linear_combination reached for .function_space and .x on whatever
extract_linear_combination returned, which UFL types as BaseCoefficient.
Newer UFL types that tightly enough for mypy to reject it, failing the
formatting job on main since before this branch. Assigning a linear
combination genuinely needs the DOLFINx Function that carries the degrees
of freedom, so check for one and say so, rather than reaching for an
attribute UFL does not promise.
Comment thread demos/time_distributed_control2.py Outdated
@@ -0,0 +1,139 @@
# # Time-distributed control
# Based on example from https://dolfin-adjoint.github.io/dolfin-adjoint/documentation/time-distributed-control/time-distributed-control.html

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do you need this demo, and the time_distributed_control checkpointing demo. Which should be kept and why?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This remove pushed by accident. I have now removed it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Understood — I picked up your removal when I merged main into the branch, so the file is gone here too. Nothing outstanding on this one.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
stored alongside the owned ones, which keeps restoring free of communication -- see `_layout`.

Snapshot checkpoints are therefore not portable. They cannot be reopened by a later run, or on a
different number of processes. For a checkpoint that outlives the run, use ``io4dolfinx``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Use {py:mod}io4dolfinx.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — {py:mod}io4dolfinx`` in the module docstring, and the demo links to the repository.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated


def _import_h5py():
try:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Add note regarding lazy import in python 3.15 https://docs.python.org/3.15/reference/simple_stmts.html#lazy-imports

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

h5py is a declared dependency now, so the lazy import has gone entirely and it is imported at module scope.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
if not shared_file:
return n_local, n_local, 0
# Collective, but called only from the write path, which every process reaches together.
sizes = comm.allgather(n_local)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Replace with exscan.
As the following example shows:

from mpi4py import MPI
import time
import numpy as np

comm = MPI.COMM_WORLD

rng = np.random.default_rng(seed=42)
z = np.random.randint(0, 100, size=1)[0]

start_time = time.perf_counter()
vals = comm.allgather(z)
offset = np.sum(vals[: comm.rank])
end_time = time.perf_counter()

start_ex = time.perf_counter()
offset_ex = comm.exscan(z, op=MPI.SUM)
if comm.rank == 0:
    offset_ex = 0
end_ex = time.perf_counter()

np.testing.assert_array_equal(offset, offset_ex)
print(f"Rank {comm.rank}: offset = {offset}, time taken = {end_time - start_time:.6f} seconds")
print(f"Rank {comm.rank}: offset_ex = {offset_ex}, time taken = {end_ex - start_ex:.6f} seconds")
print(
    f"Ratio allreduce/exscan: {end_time - start_time:.6f} / {end_ex - start_ex:.6f} = {(end_time - start_time) / (end_ex - start_ex):.2f}"
)
root@docker-desktop:~/shared# mpirun --allow-run-as-root  -n 8 python3 mwe_exscan.py 
Rank 3: offset = 201, time taken = 0.001738 seconds
Rank 3: offset_ex = 201, time taken = 0.000080 seconds
Ratio allreduce/exscan: 0.001738 / 0.000080 = 21.78
Rank 1: offset = 95, time taken = 0.000637 seconds
Rank 1: offset_ex = 95, time taken = 0.000075 seconds
Ratio allreduce/exscan: 0.000637 / 0.000075 = 8.47
Rank 6: offset = 330, time taken = 0.001919 seconds
Rank 6: offset_ex = 330, time taken = 0.000077 seconds
Ratio allreduce/exscan: 0.001919 / 0.000077 = 24.76
Rank 2: offset = 145, time taken = 0.001058 seconds
Rank 2: offset_ex = 145, time taken = 0.000079 seconds
Ratio allreduce/exscan: 0.001058 / 0.000079 = 13.37
Rank 5: offset = 283, time taken = 0.001528 seconds
Rank 5: offset_ex = 283, time taken = 0.000081 seconds
Ratio allreduce/exscan: 0.001528 / 0.000081 = 18.83
Rank 4: offset = 254, time taken = 0.001921 seconds
Rank 4: offset_ex = 254, time taken = 0.000077 seconds
Ratio allreduce/exscan: 0.001921 / 0.000077 = 24.96
Rank 7: offset = 416, time taken = 0.000133 seconds
Rank 7: offset_ex = 416, time taken = 0.000075 seconds
Ratio allreduce/exscan: 0.000133 / 0.000075 = 1.77
Rank 0: offset = 0.0, time taken = 0.000638 seconds
Rank 0: offset_ex = 0, time taken = 0.000073 seconds
Ratio allreduce/exscan: 0.000638 / 0.000073 = 8.73

exscan is always faster.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — comm.exscan(n_local, op=MPI.SUM) with the rank-0 None handled, plus one allreduce for the total length, which the dataset has to be sized with. Thanks for the benchmark; the prefix-sum-over-allgather was me reaching for the first thing that worked rather than the operation this actually is.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
rolling to a new one when the tape resets, and tearing down.
"""

def __init__(self, path: str, comm: MPI.Comm, use_mpio: bool, cleanup: bool):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

use pathlib.Path here as input instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — pathlib.Path for the file path, the checkpointer directory, and the dirname argument.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
Comment on lines +85 to +86
self.path = path
self.comm = comm

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Store with _path and _comm and make @property decorators to fetch them.
same for shared_file below.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — _path, _comm and _shared_file with @property accessors.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
rolling to a new one when the tape resets, and tearing down.
"""

def __init__(self, path: str, comm: MPI.Comm, use_mpio: bool, cleanup: bool):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Document what input args do. It is unclear what cleanup does.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. cleanup now says what it controls: whether the files and the temporary directory are deleted on teardown, with the note that they are unreadable by a later run either way, so keeping them is only useful for debugging.

Comment thread src/dolfinx_adjoint/checkpointing.py
Comment thread src/dolfinx_adjoint/checkpointing.py
Comment thread src/dolfinx_adjoint/checkpointing.py
Comment thread src/dolfinx_adjoint/checkpointing.py Outdated

__slots__ = ("_file", "_key", "_space", "_cls", "_n_local", "_offset", "_name", "_cache", "__weakref__")

def __init__(self, file: _CheckpointFile, key: str, function: dolfinx.fem.Function, n_local: int, offset: int):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Document input arguments.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated

__slots__ = ("_file", "_key", "_space", "_cls", "_n_local", "_offset", "_name", "_cache", "__weakref__")

def __init__(self, file: _CheckpointFile, key: str, function: dolfinx.fem.Function, n_local: int, offset: int):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should the input be a dolfinx_adjoint.Function or the parent class dolfinx.Function?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Changed to dolfinx_adjoint.Function — only overloaded types are ever checkpointed, so the parent class was too loose. It is imported under typing.TYPE_CHECKING since dolfinx_adjoint.types imports this module at runtime.

Comment thread src/dolfinx_adjoint/checkpointing.py
Comment thread src/dolfinx_adjoint/checkpointing.py
Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
class _DiskCheckpointer(TapePackageData):
"""Tape-attached state owning the checkpoint files for one tape."""

def __init__(self, directory: str, comm: MPI.Comm, use_mpio: bool, cleanup: bool, owns_directory: bool):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Document inputs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
"use_mpio=True requires an MPI-enabled build of h5py. Use use_mpio=False to write "
"one checkpoint file per process instead."
)
owns_directory = dirname is None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here we need an all-gather to ensure that all processes owns directory or not, as it can cause a deadlock in _DiskCheckpointer at

def close(self) -> None:
        """Close the current file and remove the directory if this object created it."""
        self._file.close()
        self._storing = False
        if self._owns_directory:
            self._comm.Barrier()
            if self._comm.rank == 0:
                try:
                    os.rmdir(self._directory)
                except OSError:  # pragma: no cover - non-empty when cleanup was disabled
                    pass

if self._owns_directory is not synced across all processes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, and it is a real deadlock. Fixed: the processes now agree explicitly rather than each deciding for itself.

without_dirname = comm.allreduce(int(dirname is None), op=MPI.SUM)
if without_dirname not in (0, comm.size):
    raise ValueError("dirname must be given on every process or on none of them, ...")
owns_directory = without_dirname == comm.size

So a mixed call fails immediately with a clear message instead of hanging in close().

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
self._storing = False


def maybe_disk_checkpoint(function: dolfinx.fem.Function) -> typing.Optional[SnapshotCheckpoint]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this the correct class, did you say that we always want to use dolfinx_adjoint.Function.

Furthermore, use SnapshotCheckpoint | None instead of typing.Optional.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Changed to dolfinx_adjoint.Function, and to SnapshotCheckpoint | None. I have replaced typing.Optional throughout the module while I was there.

Comment thread src/dolfinx_adjoint/checkpointing.py Outdated


def enable_disk_checkpointing(
dirname: typing.Optional[str] = None,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Use pathlib.Path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

The parallel test run hung intermittently -- about one run in three
locally, and in CI it sat for 42 minutes before being cancelled. Two ranks
were in different collectives at once: one inside dolfinx's mpi_jit, the
other inside dolfinx.fem.petsc.LinearProblem.__del__, which destroys the
KSP and matrices and is collective.

Every problem.solve() creates a block owning its own LinearProblem, and
pyadjoint blocks sit in reference cycles, so a discarded tape's solvers are
freed by the cyclic garbage collector rather than by refcounting. That runs
when a process crosses an allocation threshold, which is not the same
moment on every process, so one process enters a collective destructor the
others are not in. Checkpointing made it likely by recomputing the forward
many times over.

Collect deliberately at points every process reaches together -- around
each test and before building a tape -- so those destructors stay in step.
Twelve consecutive parallel runs of the full suite now pass where two in
three hung before. The underlying hazard is architectural: a block should
not own PETSc objects it cannot destroy deterministically. Sharing one
solver across a problem's blocks is the real fix, and is the solver-reuse
work already planned separately.

Also stop allocating the adjoint right-hand side per evaluation and reuse
it, which removes another per-call collective allocation from that path.

Review comments: exclusive scan instead of gathering every size, pathlib
for paths, private attributes behind properties, documented arguments,
cross-reference roles, imports at module scope, dolfinx_adjoint.Function in
annotations, and modern optional syntax.

Whether the checkpoint directory was created here is now agreed across
processes rather than assumed: disagreeing would deadlock teardown, which
synchronises before removing it.

Perturbation directions are built with annotation stopped; they are inputs
to a test, not part of the model.
Comment thread src/dolfinx_adjoint/blocks/solvers.py Outdated
"""
with stop_annotating():
if isinstance(u, _Function):
return Function(u.function_space, name=u.name + "_initial_guess")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't this and the next line then use _Function(...) as a constructor.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No — and thank you for asking, because the asymmetry is deliberate and it read like an oversight, which means it needed saying in the code rather than in a thread.

_Function is dolfinx.fem.Function; Function is our overloaded subclass. The check has to be against the base class, because u may be either a plain function or an overloaded one and the overloaded type is a subclass of it. The construction has to be of the overloaded type, because what this returns is what recompute_component hands back, and that becomes the block's output on the tape. A plain dolfinx.fem.Function cannot checkpoint itself, so constructing one here reintroduces exactly the failure this helper exists to prevent — it is the bug this PR opened with.

I have narrowed the return annotation to the overloaded type and spelled the distinction out in the docstring, so the next reader gets the answer without having to ask.

Nonmatching interpolation landed on main while this branch was open, and
the resulting conflict stopped GitHub computing a merge ref, which is why
the pull-request workflows silently stopped running: only the
push-triggered documentation build was left.

Both sides added a demo to the table of contents; kept both, with the
checkpointing demo next to the one it extends.

The index_map property main added is annotated dolfinx.cpp.la.IndexMap,
which does not exist -- the type is dolfinx.cpp.common.IndexMap, publicly
dolfinx.common.IndexMap. mypy rejects the merged tree without this, so it
is corrected here rather than left to fail the formatting job.
The isinstance check is against dolfinx's Function and the construction is
of the overloaded one, which reads like an oversight when the two names are
an underscore apart. It is not: what arrives is only known to be a
dolfinx.fem.Function, because the overloaded type is a subclass and either
may be passed, while what leaves ends up on the tape and so must be
overloaded. Constructing the plain one would reintroduce the failure this
helper exists to prevent.

Return annotation narrowed to the overloaded type to match.
Comment thread src/dolfinx_adjoint/checkpointing.py Outdated
Comment on lines +341 to +343
if _checkpointer is None or not _checkpointer.storing:
return None
return _checkpointer.store(function)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Don't we need to add a

global  _checkpointer

here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No — global is only needed to rebind a module-level name. maybe_disk_checkpoint only reads _checkpointer, and a read resolves through the module globals at call time, so it always sees the current value. The two functions that assign to it, enable_disk_checkpointing and disable_disk_checkpointing, do both declare global.

Adding it here would be harmless but misleading, since it would suggest this function reassigns the checkpointer when it does not.

Demonstrated against the built module:

before enable : None
after enable  : _DiskCheckpointer
storing=False ->  None
storing=True  ->  SnapshotCheckpoint
after disable : None -> maybe_disk_checkpoint: None

The disk tests are the standing proof of the same thing: they only pass because maybe_disk_checkpoint picks up the checkpointer that enable_disk_checkpointing installed.

finsberg and others added 11 commits August 28, 2026 08:29
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	_config.yml
#	docs/bibliography.bib
#	src/dolfinx_adjoint/blocks/solvers.py
The demo solved into `u_0` while `u_0` was also a coefficient of `L`, which
`LinearProblem` rejects, so `solve_heat` raised on the very first call and none
of the demo ran -- including the disk-checkpointing section, the only
documentation of `enable_disk_checkpointing`. It is executed by the docs build.

Split the unknown from the previous state, as the sibling demo and the
checkpointing tests already do, and update the state with an explicit
tape-recorded assignment. Also return the `LinearProblem` so callers can keep it
alive: without that it is collected when `solve_heat` returns, and replay
rebuilds an equivalent one, warning as it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_snes_time_loop_gradient_is_correct was marked xfail(strict=True) against a
serial defect -- ufl.adjoint raising IndexError on an argument-less form -- that
has since been fixed. It XPASSed, so pytest exited non-zero and CI was red.

The marker also claimed NonlinearProblem could not be covered by the
checkpointing tests at all. It can: the gradient now matches the uncheckpointed
one and Taylor-tests at rate 2 under Revolve, for a constant diffusivity and for
a solution-dependent one -- the latter being the case that puts the unknown into
the block's own dependencies, which nothing exercised before. Both are now
tested, and the docstring's dangling reference to a
test_solution_dependent_jacobian_is_unsupported that was never written is gone.

These run on one process only. On two the SNES time-loop replay is
intermittently and structurally wrong (a functional several times too large,
in 10-50% of runs). That defect is pre-existing -- it reproduces on main, more
often than here -- and is not about checkpointing, since it needs no schedule.
Skipping there keeps the mpirun -n 2 CI job honest rather than flaky; the defect
is tracked in .scratch/snes-time-loop-parallel/, with the reproducer and what
has been ruled out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The active checkpointer was held in a module global as well as in the tape's
package data, and the global drove the file's lifetime. Enabling disk
checkpointing a second time closed and unlinked the HDF5 file that the first
tape's live SnapshotCheckpoints still point into, so re-evaluating a reduced
functional built on that tape died with a KeyError from h5py reading a closed
handle. disable_disk_checkpointing() had the mirror-image problem: it popped the
package key off whichever tape happened to be working rather than off the tape
that registered the checkpointer, leaving the real owner holding a checkpointer
with no file -- which still satisfies pyadjoint's "disk storage is configured"
check, so the failure surfaced much later, at the first restore.

The tape's package data is now the only place a checkpointer is held, and every
lookup goes through the working tape. Enabling on a second tape leaves the first
one's file open and readable. disable_disk_checkpointing takes an optional tape
so a tape that is no longer current can still be torn down.

Nothing closes a file implicitly, and that is deliberate rather than an
oversight: closing a shared file is collective, so it cannot be driven by
garbage collection. A tape whose checkpointing is never disabled keeps its files
until the process exits, which the docstring now says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checkpoint file grew without bound: every evaluation of the reduced
functional writes a fresh checkpoint for each state it recomputes and drops the
previous one, but nothing unlinked the datasets those dropped checkpoints had
been reading. Measured at 77 datasets and 80 KB after one evaluation of a
six-step model, then +30 datasets and ~32 KB for each one after -- so an
optimisation loop on a real mesh fills the disk, which is the opposite of what
moving checkpoints off the heap is for.

Each file now tracks its readers weakly, and Tape.reset -- the one hook
pyadjoint calls on every process together, and so the only place a collective
unlink from a shared file is safe -- drops the datasets none of them refer to
any more. HDF5 does not shrink the file, but the freed space goes to its own
allocator and later datasets reuse it. The same model now settles at 76 datasets
and ~84 KB and stays there, measured over fifteen evaluations.

Which checkpoints are dead is a question about each process's reference counts,
which need not drop at the same moment, so a shared file takes the union across
processes: a dataset goes only once no process can still read it, and every
process drops exactly the same ones in the same order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review flagged that always going through _ad_create_checkpoint allocates a
function on every recompute, where the previous in-place reuse did not, and that
the reuse was safe with no schedule active. Both are true, but measurement says
the regression is not worth acting on: over an evaluation and its adjoint of a
40-step heat equation, all 160 _ad_create_checkpoint calls together take 2.9 ms
of 263 ms, and end-to-end this branch and main are indistinguishable. A second
code path guarded on "no schedule is active" would buy about one percent in
exchange for a correctness invariant about pyadjoint's internals, so it is
written down here instead of being built.

Also corrects the FunctionAssignBlock comment, which attributed the rule to
ADR-0001. ADR-0001 says something narrower -- that a block must not put a
non-overloaded value on the tape. The actual hazard is that TimeStep.checkpoint
stores BlockVariable._checkpoint itself for a global dependency rather than a
copy, and restore_from_checkpoint hands that same object back.

Comment-only, so the mypy hook was skipped: it reports five errors in
blocks/solvers.py that main reports identically (whole-project mypy is 10 errors
in 4 files on both), and this commit changes no code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… pin

Review read `requires-python = ">=3.12"` as an unexplained narrowing that
contradicted the retained `typing_extensions; python_version < '3.11'` marker,
and read the commit message that first added the floor as evidence it should be
3.10. The floor is right and the marker is what was stale.

src/dolfinx_adjoint/typing_utils.py uses PEP 695 `type X[T] = ...` aliases,
which are 3.12 syntax rather than a library feature, so on 3.10 or 3.11 the
package raises SyntaxError on import. That file is on main and predates this
branch, so the declaration was catching up with the code rather than restricting
it. Written down in the file now, so it does not get corrected downwards again.

typing_extensions is imported nowhere in the package, and under a 3.12 floor its
`python_version < '3.11'` marker could never be satisfied anyway, so the
dependency is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@finsberg
finsberg requested a review from jorgensd September 4, 2026 12:55
Sign up for free to 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.

2 participants