Skip to content

AuxiliaryOperatorSNES#5119

Open
JHopeCollins wants to merge 14 commits into
releasefrom
JHopeCollins/auxopsnes
Open

AuxiliaryOperatorSNES#5119
JHopeCollins wants to merge 14 commits into
releasefrom
JHopeCollins/auxopsnes

Conversation

@JHopeCollins
Copy link
Copy Markdown
Member

@JHopeCollins JHopeCollins commented May 15, 2026

If I am trying to solve F(u)=0 but for whatever reason Newton isn't working or is slow to converge, this snes type enables specifying an auxiliary nonlinear operator G(u) that can be used as a nonlinear preconditioner.

The simplest form of nonlinear preconditioning is a for a nonlinear Richardson iteration:

$$G(u^{k+1}) = G(u^{k}) - F(u^{k})$$

where the solution uhat of F(uhat)=0 is clearly a fixed point.

There's a demo for the Allen-Cahn equation where Newton fails but using a G that lags a particular term in F succeeds. AuxiliaryOpertorSNES can also be used for continuation methods like Reynolds continuation, and many other methods.

This cherrypicks the AuxiliaryOperatorSNES from #4544 because it is ready to go but the FieldsplitSNES could do with a little more stress testing/tidying up.

Pointing at release because this is strictly new functionality. The edits to firedrake/preconditioners/base.py are just updating the docstrings to be clearer about the distinction between PCBase and SNESBase.

@danshapero danshapero force-pushed the JHopeCollins/auxopsnes branch from 26f7c09 to 0d93801 Compare May 15, 2026 20:57
@JHopeCollins JHopeCollins marked this pull request as ready for review May 19, 2026 13:58
Copy link
Copy Markdown
Member

@dham dham left a comment

Choose a reason for hiding this comment

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

minor changes pablo has requested to the demo.

Comment thread demos/nonlinear_pc/nonlinear_pc_allen_cahn.py.rst
Comment thread demos/nonlinear_pc/nonlinear_pc_allen_cahn.py.rst
# with forcing b = G(k) - F(k)
Gk = replace(Gk1, {uk1: uk})
b = Cofunction(V.dual())
Gk1 -= b
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gk1 is not G(u^{k+1}) anymore. Is this the right name? How about F_aux?

Comment on lines +138 to +139
self.Gk = Gk
self.Gk1 = Gk1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do these need to be stored as class attributes?


# G(u^{k+1}) - b = 0
self.uk1.assign(self.uk)
self.solver.solve()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We might want to enable solver.solve(rhs=b) so the logic with Gk1 becomes clearer. Obviously a separate PR.

Gk, bcs=bcs,
form_compiler_parameters=ctx.fcp,
options_prefix=prefix
).assemble
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Potentially one could reuse the residual assembler in the _SNESContext of the solver defined below, similar to what we did here for AssembledPC

https://github.com/firedrakeproject/firedrake/pull/5079/changes

Up to you if you want to defer this job to a separate PR.

can create problems for others, for example when using the multigrid method.
Instead, we've opted to call the parent class's `form` method, which will
return the original variational form and boundary conditions. We then discard
the original variational form, which we aren't using here.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe mention that we can also manipulate F

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.

Is that safe? Then the inner problem is mutating the outer problem, which seems impolite

Copy link
Copy Markdown
Contributor

@pbrubeck pbrubeck May 20, 2026

Choose a reason for hiding this comment

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

I mean creating a new G from F, e.g. G = F + extra_terms

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ufl.Forms are supposed to be immutable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also F += extra_terms will not modify F "in-place", it'd just assign a new Form to F.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ok, I added a note explaining that you can also come up with the form by altering F instead of creating it from whole cloth.

Comment on lines +15 to +18
def form(self, snes, state, func, test):
prefix = (snes.getOptionsPrefix() or "") + f"snes_{self._prefix}"
w = fd.PETSc.Options().getScalar(prefix + "w")
return f(func, test, w=w), None
Copy link
Copy Markdown
Contributor

@pbrubeck pbrubeck May 19, 2026

Choose a reason for hiding this comment

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

Suggested change
def form(self, snes, state, func, test):
prefix = (snes.getOptionsPrefix() or "") + f"snes_{self._prefix}"
w = fd.PETSc.Options().getScalar(prefix + "w")
return f(func, test, w=w), None
def form(self, snes, state, func, test):
F, bcs = super().form(snes, state, func, test)
prefix = (snes.getOptionsPrefix() or "") + f"snes_{self._prefix}"
w = fd.PETSc.Options(prefix).getScalar("w")
return f(func, test, w=w), bcs

y.aypx(-1, x)

@PETSc.Log.EventDecorator()
def form(self, snes, uk: Function, uk1: Function, test: Argument):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe uold, unew would be more clear 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.

uk1 and uk matches the equations describing the iteration in the class docs and the demo so they are clearer here than old/new

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sure, with the context of the docstrings, you could tell that uk1 refers to u_{k+1}. But a subclass will not include the docstring, so the context will get lost easily in user code. If a user cargo-culted this notation, it be hard to tell whether uk1 refers to u_{k-1} or u_{k+1}.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I had to puzzle over this for a moment when I was writing the demo. I agree that a different naming scheme could be beneficial. Is there a convention established elsewhere in Firedrake?

class AllenCahnAuxSNES(firedrake.AuxiliaryOperatorSNES):
def form(self, snes, u_k, u, v):
F, bcs = super().form(snes, u_k, u, v)
return (eps * inner(grad(u), grad(v)) + (u**3 - u_k) * v) * dx, bcs
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please correct me if I'm wrong, but "lagging" the negative term here is equivalent to dropping it from G.

Also having G(u^k, u^{k+1}) = F(u^k) when u^{k+1} = u^k gives you no extra source term (b).

Maybe it'd be worth adding a comment.

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.

To your second point, this is the exactly the point about the stationary point of the iteration

Comment thread demos/nonlinear_pc/nonlinear_pc_allen_cahn.py.rst Outdated
Comment thread demos/nonlinear_pc/nonlinear_pc_allen_cahn.py.rst Outdated
Comment thread demos/nonlinear_pc/nonlinear_pc_allen_cahn.py.rst Outdated

.. math::

G(u) = \int_\Omega\left(\frac{\epsilon}{2}|\nabla u|^2 + \frac{1}{4}(1 - u^2)^2\right)dx.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Now G(u) means a different thing. A better name would be E(u)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've changed this as you suggest. It would be a bigger change but I could instead define E(u) at the very beginning and work almost entirely from this, defining F = firedrake.derivative(E, u).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you do that, then the demo does not run in complex mode

Comment on lines +163 to +175
with self.uk.dat.vec_wo as vec:
x.copy(vec)

# b = G(u^{k})
self.assemble_gk(tensor=self.b)

# b = G(u^{k}) - F(u^{k})
with self.b.dat.vec as vec:
vec -= f

# G(u^{k+1}) - b = 0
self.uk1.assign(self.uk)
self.solver.solve()
Copy link
Copy Markdown
Contributor

@pbrubeck pbrubeck May 20, 2026

Choose a reason for hiding this comment

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

We can get Gk-f out of Gk1-b (no need to generate code twice). For the moment, one needs an extra buffer self.b1, but this extra buffer might go away once #5130 lands

Suggested change
with self.uk.dat.vec_wo as vec:
x.copy(vec)
# b = G(u^{k})
self.assemble_gk(tensor=self.b)
# b = G(u^{k}) - F(u^{k})
with self.b.dat.vec as vec:
vec -= f
# G(u^{k+1}) - b = 0
self.uk1.assign(self.uk)
self.solver.solve()
# u^{k+1} = u^{k} = x
with self.uk.dat.vec_wo as vec:
x.copy(vec)
with self.uk1.dat.vec_wo as vec:
x.copy(vec)
# b = f
with self.b.dat.vec_wo as vec:
f.copy(vec)
# b1 = G(u^{k}; u^{k}) - F(u^{k})
self.solver._ctx._assemble_residual(tensor=self.b1)
# b = b1
self.b.assign(self.b1)
# solve G(u^{k}; u^{k+1}) - b = 0 for u^{k+1}
self.solver.solve()

::

solver_parameters = {
"snes_type": "nrichardson",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We could try anderson as well, it reduces iterations down to 11 for this example

Suggested change
"snes_type": "nrichardson",
"snes_type": "anderson",
"snes_anderson_m": 2,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I considered this but wanted to keep it simple at first. I could add Anderson or NGMRES at the end. But I was planning another demo showing more advanced techniques using the Navier-Stokes equations in a later PR.

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.

How about just a sentence along the lines of "we could improve the convergence by swapping to an accelerated SNES type such as Anderson or ngmres, which you may want to experiment with in practice/in your own application"

},
}

solver = NonlinearVariationalSolver(problem, solver_parameters=solver_parameters)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It is unclear that the failed solve did not update the value of u. We should make it obvious that we are starting from the same initial guess

Suggested change
solver = NonlinearVariationalSolver(problem, solver_parameters=solver_parameters)
u.interpolate(initial_guess)
solver = NonlinearVariationalSolver(problem, solver_parameters=solver_parameters)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have this further up, immediately after the initial Newton solve failed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ah right

def initialize(self, snes):
from firedrake import ( # circular import if this is at file level
NonlinearVariationalSolver, NonlinearVariationalProblem,
Function, TestFunction, Cofunction)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Function is imported at the top of this file. Are we sure all of these give circular imports?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants