Skip to content

Preserve finite element pullbacks when collecting monomials - #5362

Draft
pbrubeck wants to merge 12 commits into
mainfrom
pbrubeck/zany-matvec
Draft

Preserve finite element pullbacks when collecting monomials#5362
pbrubeck wants to merge 12 commits into
mainfrom
pbrubeck/zany-matvec

Conversation

@pbrubeck

@pbrubeck pbrubeck commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

TLDR

This is the TSFC half of the FIAT stack that ends at firedrakeproject/fiat#286.

It does two things. TSFC now costs two factorisation plans and keeps the
cheaper one
, instead of choosing by a rule. And Loopy now puts adjacent
shared tabulations in one loop
, instead of one loop each.

Raviart--Thomas in 3D loses a third of its operations. Q and NCE do not change.

What this does

Cost the two plans (tsfc/spectral.py).

A pullback reaches TSFC as a sum over one argument axis. There are two ways to
handle it.

  • Expand it. This exposes scalar factorisation across its entries.
  • Keep it. This exposes one tabulation that several argument axes can share.

Neither wins everywhere. So flatten builds both plans and keeps the cheaper
one by estimate_cost. It skips the second plan when has_linear_maps says
there is no map to keep, so the extra work is only paid where it can help.

On a CG degree 3 Laplacian in 2D, keeping the map costs 8,305 operations against
8,125 for expanding it. Only a cost model rejects it there.

Fuse adjacent tabulations (tsfc/loopy.py).

One kept map per ComponentTensor gave one loop each, so a single fused nest
became three loops over the same extent. TSFC now reuses one iname per bound
index and fuses them back.

The iname is reused only between adjacent tabulations. Impero can place
other statements between two tabulations that depend on each other, and one
iname cannot be both inside and outside such a statement. statement_block
therefore resets the memo on any statement that is not a ComponentTensor
Evaluate.

Follow the GEM module split. FIAT moved the cost model to gem.cost and
renamed eliminate_deltas to cancel_nested_deltas.

Effect

Raviart--Thomas is where keeping the pullback pays. On
inner(u, v)*dx + inner(curl(u), curl(v))*dx, main against this PR:

family dim degree flops main flops PR AST lines main AST lines PR
RT 2 3 23,318 18,038 90 81
RT 2 5 253,051 192,564 90 81
RT 3 3 374,589 255,933 136 109
RT 3 5 12,965,694 8,711,463 136 109

Arithmetic falls 32% at degree 3 and 33% at degree 5 in 3D. There are 31% fewer
scalar temporaries and 20% fewer lines of C.

Q and NCE do not change. On a tensor-product cell no sum depends on exactly one
argument index, so the second plan is never built.

On time this reaches parity, not a speedup. Best of four interleaved
repetitions:

case main (s) PR (s) change main -fno-fast-math PR change
RT3 3D 0.014052 0.013975 -0.5% 0.015036 0.013638 -9.3%
RT5 3D 0.068845 0.068857 +0.0% 0.075158 0.068414 -9.0%

PyOP2 compiles with -ffast-math. gcc may then turn a*x + a*y into
a*(x + y) and hoist the sum out of the loop. That is the same saving that
keeping the map makes structurally. Forbid the reassociation and the two
separate: main loses 7.0% and 9.2%, and this branch loses 2.4% and 0.6%.

So the counted 32-33% is real, and the C compiler was already collecting most
of it. What this PR adds is a third fewer operations to schedule, and a
representation that does not depend on gcc choosing to reassociate.

Validation

  • tests/tsfc and the zany helmholtz, interpolate and projection regression
    suites: 437 passed
  • test_impero_loopy_flop_counts.py covers the ComponentTensor case that
    count_flops used to turn into a silent zero
  • test_sum_factorisation.py covers the plan selection

Notes

A DROP BEFORE MERGE commit adds a step to
.github/actions/install/action.yml. It installs the head of the FIAT stack
over the one pyproject.toml resolves from main, so CI exercises both halves
together. Revert it once they land.

AI assistance

Claude Code was used for implementation, benchmarking, and drafting this
section. The human contributor remains responsible for understanding,
validating, and maintaining the changes.

pbrubeck and others added 2 commits August 20, 2026 11:23
Expanding a pullback exposes scalar factorisation across its entries;
preserving it exposes a physical basis that both argument axes share.
Neither dominates: preserving wins on the Piola mapped families, where
the geometry otherwise crosses the element tensor contraction twice, and
expanding wins on Lagrange at moderate degree, where the entries carry
enough structure to fold.

Factorisation is therefore parameterised on that choice and run twice,
and the cheaper plan by estimate_cost is kept. The second run is skipped
when no sum spans exactly one argument axis, which is every tensor
product cell here: sum factorisation has already split the basis into one
dimensional factors and contracted the Jacobian into the per point
geometry, so no mapped tabulation exists to share and the search would
cost compile time for an identical plan.

Preserved maps must survive finalisation to be shared at all, so
spectral mode keeps its ComponentTensors.

Measured on inner(u, v)*dx + inner(d(u), d(v))*dx, d the family's
derivative, against the same tree without this change:

    RT tetrahedra degree 5   12,965,694 -> 8,711,463 flops, 33% fewer
    RT tetrahedra degree 3      374,589 ->   255,933 flops, 32% fewer
    RT triangles  degree 5      253,051 ->   192,564 flops, 24% fewer
    CG tetrahedra degree 1          432 ->       390 flops, 10% fewer
    Q, NCE hexahedra                       unchanged, no map to preserve

Scalar temporaries fall 31% and AST lines 11% on RT tetrahedra, and no
case regresses on flops. Compile time rises by up to 17% where a second
plan is built, and is unchanged elsewhere.

Fewer operations do not yet make a faster kernel. The RT bilinear kernel
runs 11% slower on triangles and 4.5% slower on tetrahedra at degree 3,
reproducibly: each shared map becomes its own ComponentTensor, and
scheduling gives each one its own loop, so one fused loop over the basis
becomes three over the same extent. Stacking the maps that share an
extent into one tensor is what this needs next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A preserved linear map is materialised as a ComponentTensor, and the loopy
backend minted a fresh iname for each one, so several maps over one extent
became several loops where expansion emits a single fused nest.  That
fission cost 7-11% of the bilinear kernel.

Reuse the iname between tabulations that the schedule places side by side.
Only adjacent ones: impero interleaves statements that depend on a
tabulation, and one iname can not sit both inside and outside such a
statement, which loopy reports as a scheduling cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pbrubeck
pbrubeck force-pushed the pbrubeck/zany-matvec branch from 39f1e41 to eee9def Compare August 20, 2026 15:01
@pbrubeck pbrubeck changed the title Preserve zany basis maps when collecting monomials Preserve finite element pullbacks when collecting monomials Aug 20, 2026
@pbrubeck
pbrubeck force-pushed the pbrubeck/zany-matvec branch from eee9def to a458257 Compare August 20, 2026 15:10
Comment thread .github/actions/install/action.yml Outdated
pbrubeck and others added 3 commits August 22, 2026 16:40
The TSFC changes here need the GEM changes in the FIAT stack
firedrakeproject/fiat#282 -> #284 -> #281 -> #286, whose head carries all
four.  Install it over the one pyproject.toml resolves from main, so that
CI exercises both halves together.

Revert this commit once the FIAT stack lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pbrubeck
pbrubeck force-pushed the pbrubeck/zany-matvec branch from e4536ab to 5bc40c9 Compare August 22, 2026 15:40
pbrubeck and others added 3 commits August 22, 2026 16:55
GEM no longer factors reductions through indirect gathers inside
optimise_monomial_sum, which the recursive sum_factorise calls at every
level.  Apply the traversal once to each finished assignment instead, so
plan costing still sees its effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A basis transformation is now a contraction against a Delta, and
delta_elimination only inspects top-level product factors, so the Delta
inside a preserved linear map never reaches it. Cancel those before
monomial collection, which recovers the gather the transformation used to
build directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dham
dham marked this pull request as draft August 25, 2026 15:29
# Conflicts:
#	tests/tsfc/test_sum_factorisation.py
pbrubeck and others added 2 commits August 29, 2026 02:08
…edrake

The install step ends with firedrake-clean, which imports Firedrake, and so
imports the tsfc that needs the GEM changes in the FIAT stack.  Swapping the
stack in from a step after that one leaves firedrake-clean to run against the
FIAT that pyproject.toml resolved from main:

    ImportError: cannot import name 'eliminate_deltas' from 'gem.optimise'

The job never reached firedrake-check.  Install the stack inline instead,
after the Firedrake install and before firedrake-clean, which is what
pbrubeck/form-interp-tsfc does with its own siblings.

Revert this commit once the FIAT stack lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
The docs job installs Firedrake itself rather than going through
.github/actions/install, so the stack this branch needs never reached it, and
it failed the same way the test jobs did before 34c5f58:

    ImportError: cannot import name 'eliminate_deltas' from 'gem.optimise'

raised by firedrake-clean at the end of its own install step.  Give it its own
copy of the install, in the same place relative to firedrake-clean.

Revert this along with 34c5f58 once the FIAT stack lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
@pbrubeck pbrubeck added base:main Run this PR using a main (dev) build LLM used An LLM was used in the production of this PR labels Aug 29, 2026
FIAT renamed eliminate_deltas to cancel_nested_deltas, and moved the cost
model to gem.cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
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.

1 participant