Skip to content

Add nls::petsc::SNESSolver, a C++ wrapper for PETSc SNES - #4433

Merged
jhale merged 55 commits into
mainfrom
jhale/cpp-snes-wrap
Aug 28, 2026
Merged

jhale merged 55 commits into
mainfrom
jhale/cpp-snes-wrap

Conversation

@jhale

@jhale jhale commented Aug 20, 2026

Copy link
Copy Markdown
Member

Wraps PETSc SNES in dolfinx::nls::petsc::SNESSolver, adapting C++ callables to the SNES callback interface. It is intended to supersede the deprecated nls::petsc::NewtonSolver.

The class owns the SNES object and holds references to the vector and matrices that define the layout of the objects the callbacks assemble into. Configuration of the solve is left to the user, via the options database or the SNES object returned by snes(). Lifecycle follows la::petsc::KrylovSolver: an MPI_Comm constructor, a (SNES, bool inc_ref_count) wrapping constructor, deleted copies, and noexcept moves.

nls::petsc::SNESSolver solver(mesh.comm());
solver.set_F([&](const Vec x, Vec b) { ... }, b);
solver.set_J([&](const Vec x, Mat A, Mat) { ... }, A);
solver.set_options_prefix("my_problem_");
solver.set_from_options();
solver.solve(x);

The name deliberately differs from the Python dolfinx.fem.petsc.NonlinearProblem. That class takes UFL forms, u and bcs, and creates its own matrices, vectors and SNES; this one takes callables and objects the caller owns. NonlinearProblem stays free for a form-based C++ class, should one be added.

Also included:

  • fem::petsc::assemble_residual and fem::petsc::assemble_jacobian, C++ counterparts of the functions of the same name in dolfinx.fem.petsc, for use as the bodies of the callbacks. Their semantics were checked step by step against the Python versions: ghost updates, zeroing, lifting with alpha = -1, set_bc, and the guard that inserts a unit diagonal only for square forms.
  • The C++ hyperelasticity demo uses both, replacing its hand-written Newton loop and its HyperElasticProblem class. It gains a line search, and converges in three Newton iterations rather than four because the hand-written residual did not lift the Dirichlet conditions.
  • set_update, exposing SNESSetUpdate. That PETSc callback takes no context argument, so the solver is recovered from a PetscContainer composed on the SNES.
  • Exceptions thrown by a callback are stored and re-thrown by solve rather than being reduced to a PETSc error code. PETSc does not restore its state as the aborted solve unwinds (x is left locked for read-only access), so this is documented as terminal for the solver.
  • KrylovSolver::solve and SNESSolver::solve mutate solver state, so neither is const. KrylovSolver::solve losing const is the only change visible to existing code; the two call sites in the tree are unaffected.
  • Fix for undefined behaviour when reading an unset options prefix: PETSc reports it as a null pointer, which was passed straight to the std::string constructor. Affected get_options_prefix on la::petsc::Vector, la::petsc::Matrix and la::petsc::KrylovSolver as well.
  • C-style casts of PETSc handles in la/petsc.cpp replaced with reinterpret_cast.

Tested with cpp/test/nls/snessolver.cpp on one, two and three ranks: solve, solve after a move, wrapping an existing SNES, a separate preconditioner matrix, the update hook, exceptions from each of the three callbacks, non-convergence, the initial guess being respected, repeated solves, and PETSc reference counts before and after destruction.

The solver only ever handles Vec and Mat, so MATNEST and VECNEST need no support and pass through untouched. There is a test for this: two decoupled blocks with different roots, MPIAIJ under MPI, preconditioned with fieldsplit since a nest matrix cannot be factored directly.

Known gaps, for reviewers:

  • The two new fem::petsc free functions have no unit test of their own; the hyperelasticity demo is their only coverage.
  • The fem::petsc assembly functions handle single forms only. Blocked and nest assembly, which the Python class supports, are not implemented: they are built on VecGhostGetLocalForm, which does not apply to a VECNEST. This is assembly-helper work, not solver work.
  • No Python bindings: Python users have petsc4py.
  • assemble_jacobian takes the preconditioner form P as a trailing defaulted pointer, so it is separated from Pmat in the argument list. Passing nullptr in the middle of the list breaks template argument deduction.
  • A SNES that outlives its SNESSolver carries callbacks pointing at a destroyed object. This is documented on the wrapping constructor rather than defended against: the function and Jacobian contexts cannot be cleared, as SNESSetFunction ignores null arguments.

AI assistance: I used Claude Code (Opus 5) to draft parts of this PR. I reviewed, edited, tested, and take responsibility for the final contribution.

jhale and others added 7 commits August 20, 2026 17:20
Adapts C++ callables to the SNES residual and Jacobian callback
interface, and owns the SNES object along with references to the vector
and matrices assembled into. Configuration of the solve is left to the
user via the options database or the wrapped SNES object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Expose SNESSetUpdate via set_update. As SNESSetUpdate takes no context
argument, the problem is recovered in the callback from a PetscContainer
composed on the SNES object.

Exceptions thrown by a callback are stored and re-thrown by solve rather
than being reduced to a PETSc error code. PETSc does not restore its
state as the aborted solve unwinds, so this is documented as terminal
for the problem.

KrylovSolver::solve and NonlinearProblem::solve mutate the solver state,
so neither is const.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the hand-written PETSc error checks in NonlinearProblem with the
CHECK_ERROR macro used by la/petsc.cpp and fem/petsc.h, and replace
C-style casts of PETSc handles with reinterpret_cast, here and in
la/petsc.cpp.

Complete the Doxygen comments on the NonlinearProblem accessors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the hand-written Newton loop and its Krylov solver with a SNES
driven by NonlinearProblem, configured through the PETSc options
database under the problem's options prefix.

The residual is now assembled into the vector that the solver passes to
the callback, rather than into a vector owned by the demo class: PETSc
line searches evaluate the residual in a work vector of their own, which
is not the vector registered with set_F.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vector and matrices registered with set_F and set_J define the
layout of the objects the solver passes to the callbacks; they are not
necessarily those objects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C++ counterparts of the functions of the same name in dolfinx.fem.petsc,
for use as the bodies of the nls::petsc::NonlinearProblem callbacks.
They copy the current iterate into the solution function, then assemble
the residual (with lifting) or the Jacobian and preconditioner into the
objects the solver passes in.

Use them in the hyperelasticity demo, which no longer spells out the
zero/assemble/ghost-update/set_bc sequence by hand. It now converges in
three Newton iterations rather than four, as the hand-written residual
did not lift the Dirichlet conditions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With assembly in fem::petsc::assemble_residual/assemble_jacobian and the
SNES owned by nls::petsc::NonlinearProblem, the class held nothing but
the objects the callbacks need. Set the solver up in main instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jhale

jhale commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

This was essentially created by Opus by being told to follow our Python NonlinearProblem and the style/intent of the two other PETSc solver wrappers.

I think NonlinearProblem should be in fem::petsc - this matches the Python interface too.

@jhale
jhale requested review from garth-wells and jorgensd and removed request for garth-wells August 20, 2026 16:13
jhale and others added 7 commits August 20, 2026 19:05
Check that the problem and the SNES each hold one reference to the
residual vector and to each matrix, that re-setting with the same object
does not accumulate references, that a Jacobian used as its own
preconditioner is referenced twice and released twice, and that
everything is released when the problem is destroyed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The callbacks hold a pointer to the problem, which is not reference
counted, so a SNES that outlives the problem carries dead callbacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PETSc reports an unset prefix as a null pointer, which was passed
straight to the std::string constructor. Affects get_options_prefix on
la::petsc::Vector, la::petsc::Matrix, la::petsc::KrylovSolver and
nls::petsc::NonlinearProblem, and crashes rather than returning "".

Point set_F and set_J at the fem::petsc functions that assemble the
residual and Jacobian of a form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cover the update hook across a move, which reaches the problem through
the container composed on the SNES rather than a context pointer;
exceptions from the Jacobian callback and the update hook, each of which
has its own trampoline; that the vector passed to solve is used as the
initial guess, by converging to the negative root from a negative guess;
and that a problem that has converged can be solved again.

The move test re-registers the hook on the moved-to problem with a
distinct target. A moved-from std::function is left in a valid but
unspecified state and remains callable on libc++, so a hook sharing
state with the moved-from one cannot show which problem was reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches dolfinx.fem.petsc.assemble_matrix, which guards the flush and
the diagonal insertion on the test and trial spaces being the same. It
makes no difference for a Jacobian, but a preconditioner form need not
be square.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The class wraps a SNES and holds callbacks; it does not hold the forms,
boundary conditions or solution function that make up a problem, so the
Python class of the same name sets the wrong expectation. SNESSolver
says what it is, and leaves NonlinearProblem free for a form-based class
mirroring dolfinx.fem.petsc.NonlinearProblem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jhale jhale changed the title Add nls::petsc::NonlinearProblem, a C++ wrapper for PETSc SNES Add nls::petsc::SNESSolver, a C++ wrapper for PETSc SNES Aug 20, 2026
@jhale
jhale marked this pull request as ready for review August 20, 2026 19:49
jhale and others added 8 commits August 21, 2026 08:07
The solver holds Vec and Mat, so MATNEST and VECNEST pass through it
untouched. Solve two decoupled blocks with different roots, so that a
mix-up between them would show up in the solution, preconditioned with
fieldsplit as a nest matrix cannot be factored directly. Under MPI the
blocks are MPIAIJ, and the suite already runs on one, two and three
ranks.

Also restore the test case description, which a rename had turned into
"Solve nonlinear solver with SNES".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SNESSetUpdate passes no context, which a PetscContainer composed on the
SNES worked around. SNESGetFunction returns the context registered by
set_F, which is the same pointer, so the container and its lifetime,
naming and re-composition on move all go away.

Guard the recovery on the residual callback being this class's own: a
caller holding the SNES can register their own function, and its context
must not be cast to a solver.

Test that solving through the SNES object directly, rather than through
solve(), works and still runs the update hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The locals named x and b shadowed the callback parameters of the same
name, which are the point the solver evaluates at and the object it
assembles into. The registered objects set the layout of the ones the
solver passes in, so they are named A_layout and b_layout, and the
vector sharing the degrees-of-freedom of u is u_vec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The assemble_jacobian example predated moving the preconditioner form to
a trailing defaulted argument, and would not compile. The
assemble_residual example registered a vector named b, shadowing the
callback parameter of the same name.

Say that the vector and matrices are the ones the solver passed to the
callback rather than those registered with it, that x can be a line
search trial point, and that u must be the function the forms hold as a
coefficient. Qualify the unit diagonal, which is only set for forms
whose test and trial spaces are the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
assemble_residual and assemble_jacobian took the object to assemble into
before the point to evaluate at, the reverse of the callback signatures
they are written for. Both arguments are Vec or Mat, so transposing them
compiles and silently assembles into the solution vector. Match the
callbacks, and dolfinx.fem.petsc, by taking x first.

Also name the callback arguments in the documentation, in the order the
solver passes them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
set_F and set_J named their vector and matrices b, Jmat and Pmat, the
same as the parameters of the callbacks they take, so the documentation
could not refer to either without ambiguity. Name the registered
objects b_layout, J_layout and P_layout, as the hyperelasticity demo
does, and name the callback arguments in the documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jhale

jhale commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

This is ready for review now - might take some small lessons back to the Python version once this is merged.

jhale and others added 6 commits August 24, 2026 11:55
Relocates the PETSc options-database helpers to common::petsc, since
they are already used outside la (nls, demos) and only depend on
common::petsc::check. Flattens options::set/clear into
set_option/set_option<T>/clear_option/clear_options rather than
keeping a third-level options namespace, matching the rest of the
library's dolfinx::<module>::<submodule> convention.
Bump end year to 2026 across touched files, and add Jack S. Hale to
files with substantial contributions in this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bring the demos onto common::petsc::check for all Mat/Vec calls, wrap
the previously bare slepc.cpp test assertions in CHECK(... == 0) to
match cpp/test/petsc.cpp, and check the unchecked MatSetOption calls
in the Python discrete-operator wrappers.

Destructors and catch-block cleanup that destroy PETSc objects are now
checked too rather than silently discarding the error code, in
anticipation of --with-strict-petscerrorcode. A thrown error in these
paths calls std::terminate instead of propagating, which each site now
notes explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Merge the two spdlog::error calls into one and fold the same file,
line, and enclosing function name into the thrown std::runtime_error
message, not just the log line. Callers that only see e.what() (e.g.
via the Python bindings) previously lost the call site entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	cpp/demo/hyperelasticity/main.cpp
#	cpp/dolfinx/fem/petsc.cpp
#	cpp/dolfinx/fem/petsc.h
#	cpp/dolfinx/la/petsc.cpp
#	cpp/dolfinx/la/petsc.h
#	cpp/dolfinx/la/slepc.cpp
@jhale
jhale changed the base branch from main to jhale/more-petsc-work August 24, 2026 12:54
jhale and others added 7 commits August 24, 2026 14:55
VecGhostUpdateBegin/End and SNESGetLinearSolveIterations were left
unchecked after the merge, unlike the other demos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Python NonlinearProblem (fem/petsc.py), the SNES-based analogue
of this wrapper, returns only the solution from solve() and leaves
iteration counts and convergence status to be queried from the SNES
object directly. Follow the same convention in C++: the iteration
count is equally available via SNESGetIterationNumber(solver.snes(),
...), so returning it from solve() was redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three call sites duplicated the same try/catch-and-store-exception
logic; factor it into one invoke() helper. store_exception() used to
re-throw and re-catch its own exception_ptr just to read the message
for logging -- unnecessary when each catch clause already has the
exception by type.

Also recover the solver in the update hook via the SNES application
context (SNESSetApplicationContext/SNESGetApplicationContext) rather
than by inspecting SNESGetFunction and checking it points at
residual(). This is more direct and does not depend on set_F having
been called.

Restore the reinterpret_cast<PetscObject> casts that the more-petsc-work
merge had turned into C-style casts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
[&] captured everything in scope; list only what each lambda
actually uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Match the KrylovSolver sibling constructor: an O(1) API-boundary
check should throw rather than assert, so a null SNES fails cleanly
in Release builds instead of proceeding into undefined behaviour.
It only touches _exception, passed explicitly, so it does not need
to be a SNESSolver member. This drops it from the class's private
interface in the header.
Base automatically changed from jhale/more-petsc-work to main August 25, 2026 11:44
num_iterations, reason_str);
}

return num_iterations;

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.

It's unclear to me the utility of returning this iteration count, same comment for the KrylovSolver.

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.

Fine to change.

# Conflicts:
#	cpp/demo/hyperelasticity/main.cpp
#	cpp/dolfinx/common/petsc.cpp
#	cpp/dolfinx/fem/petsc.h
@jhale
jhale added this pull request to the merge queue Aug 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 27, 2026
@jhale
jhale added this pull request to the merge queue Aug 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 28, 2026
num_iterations, reason_str);
}

return num_iterations;

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.

Fine to change.

@jhale
jhale added this pull request to the merge queue Aug 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 28, 2026
@jhale
jhale added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit 5b30ce8 Aug 28, 2026
23 checks passed
@jhale
jhale deleted the jhale/cpp-snes-wrap branch August 28, 2026 19:04
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.

3 participants