Optimise generic sum-factorisation lowering - #5335
Draft
pbrubeck wants to merge 27 commits into
Draft
Conversation
This was referenced Aug 7, 2026
pbrubeck
force-pushed
the
pbrubeck/optimise-sum-factor
branch
from
August 13, 2026 21:13
1b75bff to
e877052
Compare
The Loopy lowering built one iteration domain piece per parent value and unioned them. Loopy needs a convex domain, so that union was rejected for most sparsity patterns, and the bound was dropped without warning whenever the parent loop was out of scope. The sparse basis map now arrives as one rectangular contraction, so the lowering needs no such bound. test_coffee_optimise pinned the operand order of a factorised product. That order came from the greedy association, which paired the unit literal with the first factor and appended the folded result. The contraction planner associates the same product without that step, so the expected trees now name the operands in the order it produces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The benchmark compiled a form through TSFC and reported kernel metrics, so it could show neither what the generated code costs to run nor what a user waits for. It now builds a real mesh, assembles the form, and separates three costs: TSFC compilation, a cold-cache assembly, and the cell loop itself. The on-disk TSFC and PyOP2 caches are redirected to a fresh directory before Firedrake is imported, so compilation is genuinely cold rather than whatever the previous run left behind. Execution is timed by calling the compiled cell loop directly. Going through `assemble` would fold Python and PETSc work into the runtime number; on the tetrahedral form the two agree to within 2%, which is itself the useful result. Assembly overhead is negligible, and a 4.7 s cold assemble surrounds a 0.22 s loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`benchmarks/metrics.py` holds the cache isolation, kernel metrics, kernel timing and source dumping that the benchmarks have in common. `johnson_mercier.py` uses it instead of its own copies. `benchmarks/sum_factorisation.py` measures the two forms of `docs/notebooks/10-sum-factorisation.py` on an extruded hexahedral mesh: a Laplacian on CG and a curl-curl form on NCE. Each is compiled vanilla, on a Gauss-Legendre rule, and on the collocated Gauss-Lobatto-Legendre rule. Forms are measured as operator actions, so the timing sees the local contraction rather than the insertion of an element matrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contracting each assignment in its own quadrature ordering minimises arithmetic, but assignments that disagree build loop nests Impero cannot merge, so a value they share widens to the whole quadrature grid. Plan selection now also builds a candidate in which every assignment shares one ordering. Storage is a feasibility constraint rather than a term traded against arithmetic: among plans whose temporaries fit `storage_budget`, take the one with least arithmetic, and fall back to the narrowest when none fit. One rule chooses both the representation of the finite element linear maps and the contraction ordering. `_declared_storage` measures a candidate by scheduling it, since a temporary's width follows from the loop nest a value outlives, which an expression DAG does not record. Selection lives in `_select_plan`, over the orderings from `_plans` and the two representations from `_factorise`. `flatten` returns its assignments rather than yielding them, as it computes them eagerly. NCE degree 7 on a Gauss-Legendre rule falls from 96,273 to 7,018 declared scalar entries, and its largest temporary from 3,375 to 1,575, at 1.6% more arithmetic. The collocated rule keeps its per-assignment orderings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pbrubeck
force-pushed
the
pbrubeck/optimise-sum-factor
branch
from
August 17, 2026 20:00
c9c36e4 to
a696c42
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Chapter 4 of Luporini's thesis derives finite element kernel optimization from sharing elimination, reduction pre-evaluation, factorization, and generalized loop-invariant code motion. Section 4.11 calls out the need to optimize expressions outside the linear basis loops systematically, use domain knowledge about basis functions, improve the memory cost model, and generalize the method to spectral loop nests.
The paired FIAT PR, firedrakeproject/fiat#276, expresses that extension in GEM. This PR preserves the selected contraction and finite element map structure through TSFC and into Loopy. GEM chooses where indexed reductions execute; COFFEE factors scalar expressions within those loops; Loopy receives explicit value loops rather than large materialized intermediates.
Johnson--Mercier is not sum-factorizable. It is the generic benchmark because its physical basis map followed by mass and divergence contractions reveals whether the compiler preserves the intended finite element stages: transform reference tabulations first, then contract them with quadrature and geometry.
Changes
ComponentTensorvalue indices as explicit loops so mapped outputs share scalar temporaries;Johnson--Mercier code generation
The target is
for degree-one Johnson--Mercier elements on a triangle and tetrahedron. The comparison is
mainagainst this PR together with firedrakeproject/fiat#276. Compile time, kernel run time, and cold-cache assemble time are pinned-core averages over repeated runs, with the kernel timed by calling the compiled cell loop directly rather than throughassemble; the remaining values are deterministic properties of the generated Loopy kernel.The eight writable 2D arrays are six mapped basis outputs, one geometry vector, and the coefficients of the basis transformation. The mapped-tabulation loop evaluates shared scalar expressions once and writes those six outputs directly. The following loop performs the element-tensor contraction. No basis function is selected through an
ifbranch.Reproduce each side by checking out both repositories at either
mainor theirpbrubeck/optimise-sum-factorbranches and running:The script prints the table as Markdown.
Tensor-product code generation
The targets are
at degree seven on an extruded hexahedral mesh, following
docs/notebooks/10-sum-factorisation.py. Both are measured as operator actions, so the timing sees the local contraction rather than the insertion of an element matrix. Each is compiled twice: once on the default Gauss--Legendre rule, and once on the collocated Gauss--Lobatto--Legendre rule, whose tabulations are Kronecker deltas that cancel. Timings follow the protocol above.CGdegree 7, Laplacian action:NCEdegree 7, curl-curl action:Read-only table arrays and entries are unchanged in all four cases.
A collocated rule makes the value tabulation an identity. This PR turns that identity into a shorter contraction: on that rule the curl-curl kernel does 5.6x fewer FLOPs and runs 5.6x faster, from an AST 3.4x shorter that compiles 27.7% faster, and the Laplacian does 2.2x fewer FLOPs and runs 2.2x faster.
The Gauss--Legendre rule carries no such structure, and the FLOP counts are held there: exactly for
CG, and within 0.001% forNCE. Three costs come with that. TheNCEkernel is 7.4% slower, with its FLOP count and its largest temporary both matchingmain, so the remaining gap is not storage and is not yet explained. Compilation is 76.6% slower forNCEand 25.5% slower forCG, because selecting a contraction plan schedules each candidate to measure the storage it declares, whichmaindoes not do; compilation results are cached on disk. Writable entries rise, by 41.0% forCGand 5.6% forNCE.Reproduce each side as above, with:
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 \ taskset -c 0 python benchmarks/sum_factorisation.py \ --family CG NCE --degrees 7 --modes spectral gll --repeats 200 --size 2The script prints the table as Markdown, and
--degreessweeps polynomial degree.Validation
make srclintover the touched TSFC files, and FIAT prose checkstests/tsfc(368 passed)test_interpolate_zany,test_projection_zany,test_helmholtz_zany,test_stress_elements,test_mtw,test_macro_interp_project,test_macro_multigrid,test_stokes_macroelements,test_embedded_transfer,test_submesh_solve(217 passed)
31 xfailed)
mainat1,782,525 FLOPs, 22 scalar temporaries, 5 arrays, and 13,454 stored values
Stack
AI assistance
OpenAI Codex and Claude Code were used for implementation, refactoring, testing, benchmarking, and drafting this PR. The human contributor remains responsible for understanding, validating, and maintaining the changes.