Skip to content

TSFC: support same-mesh ufl.Interpolate in Form kernels - #5258

Open
pbrubeck wants to merge 179 commits into
mainfrom
pbrubeck/form-interp-tsfc
Open

pbrubeck wants to merge 179 commits into
mainfrom
pbrubeck/form-interp-tsfc

Conversation

@pbrubeck

@pbrubeck pbrubeck commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

TSFC now compiles same-mesh ufl.Interpolate nodes in normal Form kernels, and this PR ports the feature from PyOP2 to pyop3.

  • Standalone Interpolate and Form(Interpolate) share one code generation path.
  • Same-mesh interpolation across a MixedFunctionSpace supports mat_type="aij" natively.
  • Interpolation into a VertexOnlyMesh uses the same TSFC code generation.
  • Cross-mesh interpolation stays on BaseFormAssembler.

SameMeshInterpolator no longer builds its own op3.loops. It sends the interpolation through the form assembler:

 SameMeshInterpolator._get_callable
-  _build_interpolation_callables      # hand-built op3.loop per sub-expression
+  get_form_assembler(Interpolate, access=...)
+    tsfc_interface.compile_form(..., access)
+      as_pyop3_local_kernel(ast, nargs, access)
+        op3.Function(..., requires_zeroed_output_arguments=True)

MIN and MAX are reduced into the output as MIN_WRITE and MAX_WRITE. TSFC kernels add their result to the output argument, so pyop3 starts these temporaries from zero (see #5543).

TwoFormAssembler._apply_bc now takes the field label of a BC from the BC's own space. Before, a rectangular form such as an interpolation from W = V * V into V failed.

quadrature_space is now a cached property. The adjoint weight, the reciprocal of the number of cells in the iteration set whose closure contains each node, is cached on the mesh, keyed by the dual space and the subset.

Stacked on #5543, which adds MIN/MAX reductions to pyop3 kernel calls.
Depends on firedrakeproject/fiat#294.

Fixes #5468

Evidence

  • Before (merge of main without the fixes): 7 failures in tests/firedrake/regression/test_interpolate.py
    test_subset_interpolation_reduction[min|max]     -> all entries stay at ±finfo.max
    test_interpolate_periodic_coords_reduction[...]  -> [inf]
    test_mixed_space_bcs                             -> MultipleUniqueEntriesException
    
    After:
    serial: interpolation, cross-mesh, vertex-only, MG grid transfer, pyop3 loop and TSFC tests  1006 passed
    serial: BC, mixed, real-space, matrix and assembly regression tests                           740 passed
    mpiexec -n 2  -m "parallel[2]" interpolation and vertex-only tests                            32 passed
    mpiexec -n 3  -m "parallel[3]" interpolation and vertex-only tests                           271 passed
    

test_subset_adjoint_interpolation (new, 1 and 2 ranks) compares the matrix-free adjoint with the transposed aij matrix, with and without a subset. It fails if the subset is left out of the cache key.

Repeated interpolation on BDM2 16×16 through Vq = V.quadrature_space, per call:

before after
Function(V).interpolate(q) 4.01 ms 3.87 ms
Cofunction(Vq.dual()).interpolate(r) 15.1 ms 7.8 ms

Merge Danger

Door: two-way

Blast Radius: interpolation

All same-mesh interpolation, and the Form kernels that contain it, now go through TSFC and the form assembler. op3.Function gets a new attribute, so kernel caches from before this change must be cleared with firedrake-clean. V.quadrature_space() is now the property V.quadrature_space.

The CI commits marked DROP BEFORE MERGE install the FIAT branch and must be dropped before merging.

Contribution assisted by Claude Code (Claude Opus 5.5).

🤖 Generated with Claude Code

Comment thread tsfc/modified_terminals.py Resolved
Comment thread tsfc/fem.py Resolved
Comment thread tsfc/ufl_utils.py Resolved
Comment thread firedrake/assemble.py Resolved
@pbrubeck
pbrubeck marked this pull request as draft July 16, 2026 11:18
@pbrubeck

Copy link
Copy Markdown
Contributor Author

This PR should reimplement SameMeshInterpolator as a dispatcher to {Zero|One|Two}FormAssembler. We want to unify the TSFC codepaths for Form assembly and same-mesh interpolation.

Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread firedrake/assemble.py Resolved
Comment thread tsfc/fem.py Resolved
@pbrubeck
pbrubeck force-pushed the pbrubeck/form-interp-tsfc branch from a844911 to f7f5ee3 Compare July 17, 2026 14:42
Comment thread tsfc/driver.py Resolved
Comment thread firedrake/interpolation.py Resolved
Comment thread tsfc/driver.py Resolved
Comment thread firedrake/interpolation.py Resolved
Comment thread firedrake/assemble.py Resolved
@pbrubeck
pbrubeck force-pushed the pbrubeck/form-interp-tsfc branch from f6d360f to 3537776 Compare August 26, 2026 17:13
pbrubeck and others added 2 commits October 29, 2026 18:09
Co-authored-by: Leo Collins <leocollins511@gmail.com>
@sghelichkhani

Copy link
Copy Markdown
Contributor

Hi Pablo, I ran G-ADOPT on #5566 and repeated interpolations no longer build new loops, forward and adjoint. One case still does. A MIN/MAX interpolation into a new output builds a new loop on every call in _initialize_minmax through _loop_target_nodes. G-ADOPT does this every time step in compute_timestep and it costs 6 to 13 ms per call against 0.8 ms with pyop2. Could that loop be cached, or skipped when covers_target is true?

pbrubeck and others added 2 commits November 9, 2026 23:35
# Conflicts:
#	firedrake/assemble.py
#	firedrake/interpolation.py
#	tests/firedrake/regression/test_interp_dual.py
UFL's restructure_base_form no longer preprocesses Forms nor takes
complex_mode, so preprocess_base_form no longer needs the scalar type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sghelichkhani added a commit that referenced this pull request Nov 10, 2026
@pbrubeck
pbrubeck requested a review from leo-collins November 10, 2026 11:18
… the target

A MIN or MAX interpolation into a newly allocated output built a pyop3 loop
on every call to set the identity of the reduction on the reached nodes. When
the interpolation reaches every target cell, every node is reached, so the
identity is written directly into the dat, including the ghost nodes.

SameMeshInterpolator.covers_target holds the structural check on the subset
and the submesh hierarchy, and _can_fuse_operator uses it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pbrubeck

Copy link
Copy Markdown
Contributor Author

Hi Pablo, I ran G-ADOPT on #5566 and repeated interpolations no longer build new loops, forward and adjoint. One case still does. A MIN/MAX interpolation into a new output builds a new loop on every call in _initialize_minmax through _loop_target_nodes. G-ADOPT does this every time step in compute_timestep and it costs 6 to 13 ms per call against 0.8 ms with pyop2. Could that loop be cached, or skipped when covers_target is true?

Done.

@sghelichkhani

Copy link
Copy Markdown
Contributor

Thank you. For the record, in the G-ADOPT regression tests the loop cost about 6 ms per call against 0.7 ms before, which made each time step of forward cases such as 2d_compressible_TALA about 40 % slower. With a local change that writes the identity in the same way, the time per step was back to where it was and the results did not change.

@sghelichkhani

Copy link
Copy Markdown
Contributor

A small heads-up. With current main merged in, this PR fails test_restricted_function_space.py::test_restrict_action[False] and test_poisson_restricted_mixed_space[Cofunction] with a KeyError on an Interpolate node. Both pass on main. I think it comes from #5260 and #5285 which were merged on 10 November.

@pbrubeck

Copy link
Copy Markdown
Contributor Author

Thank you. For the record, in the G-ADOPT regression tests the loop cost about 6 ms per call against 0.7 ms before, which made each time step of forward cases such as 2d_compressible_TALA about 40 % slower. With a local change that writes the identity in the same way, the time per step was back to where it was and the results did not change.

What are the before and after here? What is the local change?

pbrubeck and others added 5 commits November 12, 2026 11:01
…ly' (#5608)

Merge only the first commit of #5608, without its DROP BEFORE MERGE commit,
since #5258 pins its own UFL branch in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
restructure_base_form moves every operator of rank above max_composed_rank
out of its integrand, and the default of 1 moves the interpolation in
inner(interpolate(u, V), v)*dx out of its Form. Pass max_composed_rank=2
when all of the operators of the expression can be fused into its kernel,
as base_form_operands reports, and keep the default otherwise, so that an
interpolation that cannot be fused is still assembled on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erpolation

test_assemble_interp_pullback_of_action needs the operands of each Action
in the restructured pullback to be numbered from zero (FEniCS/ufl#554).
test_form_interp_mapped_derivative_mixed needs join_domains to let a
MeshSequence subsume its component meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Install pbrubeck/join-domains-mesh-sequence, which stacks the join_domains
fix for MeshSequence on FEniCS/ufl#554
(FEniCS/ufl#554). Drop this commit once both have
merged into UFL main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	firedrake/interpolation.py
#	firedrake/mg/kernels.py
pbrubeck added a commit that referenced this pull request Nov 12, 2026
Expose needs_petsc on the TSFC Kernel that survives PR #5258. Leave ExpressionKernel unchanged and check its event directly in the interpolation path.

Remove the stale kernel comment and unconditional stdio include, and clarify the compiler options documentation. Check logging-enabled and logging-disabled kernels in serial and MPI.

AI assistance: Codex.
@sghelichkhani

Copy link
Copy Markdown
Contributor

Before your fix one call of this interpolation took about 6 ms in a small serial benchmark of the expression in the G-ADOPT time step adaptor. With your fix it takes 0.5 ms. The code without this PR took 0.6 ms. It wrote the identity with f.assign(Constant(finfo.min)) and built no loop. My local change was a narrower version of your fix. When there was no subset and the source mesh was the target mesh it wrote the identity value into the whole array with NumPy and skipped the loop. Your fix covers that case and the submesh case so I have dropped it. In the G-ADOPT CI the 2d_compressible_TALA case took 474 s without this PR. It took 778 s with this PR before your fix and 452 s with your fix.

@connorjward connorjward left a comment

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 think that this is fundamentally there. Please do go over all of the tests and make sure that they actually test useful things and don't do the antipatterns I pointed out.

And in future please please please make smaller PRs. Reviewing this now just took me 40 minutes (and I have reviewed it a bunch of times before).

Comment thread firedrake/functionspaceimpl.py Resolved
Comment thread tsfc/kernel_interface/common.py Resolved
Comment thread tsfc/fem.py Resolved
# its domain as a MeshSequence rather than a single mesh: every
# mesh it sequences is the same iteration, so they must agree.
if not isinstance(domain, MeshSequence):
raise

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.

raise what?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

when you raise inside an except, you raise whatever brought you there.

Comment thread tsfc/kernel_interface/common.py Resolved
# An interpolation kernel never reads the output, it only reduces its
# results into it. Therefore, MIN and MAX are the WRITE reductions.
access = expr.options.access
if access is op3.MIN_RW:

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.

This should just be an error no? Otherwise we are silently doing something the user doesn't expect and explicitly didn't ask for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The problem is the aliases for MIN == MIN_RW.

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 am open to redefining the alias. And whatever happens we're making a breaking change with pyop3. So we could just error if someone does op2.MIN -> op3.MIN and tell them to DTRT.

We could also allow this to accept strings ("min") instead of pyop3 intents. Intents in pyop3 are a more general concept than what we need here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I would rather open an issue and address it in a separate PR. For now, this does not break the API.

For now this is doing whatever the user expected with MIN before the introduction of MIN_RW / MIN_WRITE.

Comment thread firedrake/interpolation.py Resolved
Comment thread firedrake/assemble.py Outdated
if _FORM_CACHE_KEY not in form._cache:
form._cache[_FORM_CACHE_KEY] = {}
form._cache[_FORM_CACHE_KEY][key] = self
if key is not None:

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.

Why can this be None? I would prefer something more explicit to disable assembler caching.

@pbrubeck pbrubeck Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The key was None for 0- and 2-forms (we do not cache assemblers for them). I added a _cache_assemblers property (True for 1-form, False otherwise).

@pbrubeck

Copy link
Copy Markdown
Contributor Author

And in future please please please make smaller PRs. Reviewing this now just took me 40 minutes (and I have reviewed it a bunch of times before).

Thanks. I always aim to keep them short but something quite pervasive like this can't be split into multiple PRs, as you might understand (pyop3).

pbrubeck and others added 5 commits November 13, 2026 17:24
Co-authored-by: Connor Ward <c.ward20@imperial.ac.uk>
…uction

FormAssembler subclasses now opt in to assembler caching with the
_cache_assemblers flag, instead of returning a None cache key.
The MIN/MAX reduction for cross-mesh interpolation is defined where it
is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiE9EeaHedoaoiJ2UZAYKU
Comment thread firedrake/interpolation.py Resolved

This branch has not been deployed

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

Labels

base:main Run this PR using a main (dev) build LLM used An LLM was used in the production of this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Interpolating an expression whose Constant vanishes under differentiation fails an assertion in PyOP2

4 participants