Skip to content

Optimise generic GEM sum factorisation - #276

Draft
pbrubeck wants to merge 31 commits into
mainfrom
pbrubeck/optimise-sum-factor
Draft

Optimise generic GEM sum factorisation#276
pbrubeck wants to merge 31 commits into
mainfrom
pbrubeck/optimise-sum-factor

Conversation

@pbrubeck

@pbrubeck pbrubeck commented Aug 7, 2026

Copy link
Copy Markdown

Motivation

Chapter 4 of Luporini's thesis treats finite element kernel optimization as the coordination of sharing elimination, reduction pre-evaluation, factorization, and generalized loop-invariant code motion. Section 4.11 leaves several relevant directions open: systematically optimizing expressions outside the linear basis loops, exploiting redundancies in basis functions, relaxing restrictions on code motion, replacing a fixed memory threshold with a better cost model, and extending the method to jagged loop nests.

This PR addresses those limitations at the GEM level, where finite element and contraction structure are still explicit. GEM chooses the loop domain in which each reduction is evaluated; COFFEE then eliminates scalar sharing inside the resulting loop nest. Keeping those decisions separate prevents scalar algebra from obscuring a legal contraction and prevents a contraction rewrite from losing scalar factorization opportunities.

Johnson--Mercier is a useful target even though it is not sum-factorizable. Its physical basis transformation followed by mass and divergence contractions stresses exactly the boundary this PR changes: reference tabulation, sparse basis recombination, quadrature contraction, and scalar sharing.

Mathematical model

A normalized contraction is represented by a hypergraph whose factors are vertices and whose contracted indices connect the factors in which they occur. Independent connected components are planned separately. Within a component, subset dynamic programming chooses a product tree. An index is reduced at the first subtree containing every factor incident on that index, which is its earliest legal code motion.

Plans are ordered lexicographically by exact operation count, peak live intermediate storage, and total materialized storage. Rectangular and jagged iteration domains are counted from their actual index domains. This replaces an architecture-specific temporary-size threshold with explicit mathematical costs.

Physical basis recombination is kept as a transformation of the reference tabulation. The transformation is stored in compressed sparse row form and applied as one rectangular contraction, so the basis axis reaches the quadrature contraction as a single loop rather than as one expression per basis function. Rows shorter than the longest are padded with zero coefficients, and the generated kernel selects no basis function through an if branch. Explicit ComponentTensor value loops allow several mapped outputs to share scalar work without materializing that work as arrays.

Changes

  • add gem.contraction as the single planner for associative scalar trees and indexed tensor contractions;
  • preserve finite element linear maps while collecting COFFEE monomials;
  • apply COFFEE sharing elimination at the reduction levels selected by GEM;
  • apply sparse physical basis maps as one rectangular contraction over compressed sparse rows;
  • distinguish index literals from value literals when comparing and hashing Literal;
  • schedule bound component-tensor indices as explicit value loops;
  • interpret and count rectangular and jagged domains consistently;
  • add focused tests for contraction legality, cost estimation, sparse maps, map sharing, and temporary placement.

Johnson--Mercier code generation

The target is

(inner(u, v) + inner(div(u), div(v))) * dx

for degree-one Johnson--Mercier elements on a triangle and tetrahedron. The comparison is main against this PR together with firedrakeproject/firedrake#5335. 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 through assemble; the remaining values are deterministic properties of the generated Loopy kernel.

metric 2D: main → PR change 3D: main → PR change
compile time (s) 0.314 → 0.226 −28.0% 1.870 → 1.418 −24.2%
kernel run time (s) 0.000560 → 0.000565 +0.9% 0.3614 → 0.2180 −39.7%
cold-cache assemble (s) 2.623 → 1.063 2.5x faster 25.46 → 4.66 5.5x faster
FLOPs 49,571 → 40,834 −17.6% 1,859,003 → 1,032,470 −44.5%
scalar temporaries 162 → 51 3.2x fewer 1,015 → 157 6.5x fewer
writable arrays 18 → 8 2.2x fewer 48 → 15 3.2x fewer
writable entries 270 → 120 2.2x fewer 2,016 → 630 3.2x fewer
writable bytes 2,160 → 960 2.2x fewer 16,128 → 5,040 3.2x fewer
largest writable array 15 → 15 unchanged 42 → 42 unchanged
read-only table arrays 135 → 12 11.2x fewer 1,009 → 28 36x fewer
read-only table entries 1,215 → 1,254 +3.2% 16,144 → 16,270 +0.8%
AST lines 652 → 188 3.5x fewer 4,150 → 525 7.9x fewer

The eight writable 2D arrays are six mapped basis outputs, one geometry vector, and the coefficients of the basis transformation. The generated basis-map loop computes shared scalar expressions once and writes those six outputs directly; the element-tensor loop then performs the quadrature contraction.

Reproduce each side by checking out both repositories at either main or their pbrubeck/optimise-sum-factor branches and running:

OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 \
taskset -c 0 python benchmarks/johnson_mercier.py --dims 2 3

The script lives in firedrakeproject/firedrake#5335 and prints copyable Markdown.

Validation

  • python -m pydocstyle .
  • focused flake8 over all touched FIAT files
  • python -m pytest -q test/ (2457 passed, 26 skipped, 31 xfailed)
  • python -m pytest -q test/gem/test_simplify.py test/gem/test_sum_factorise.py (30 passed)
  • paired TSFC tests in Optimise generic sum-factorisation lowering firedrake#5335 (368 passed)
  • the physically mapped regression suites in Optimise generic sum-factorisation lowering firedrake#5335 (217 passed)
  • exact 2D and 3D JM kernel inspection and metric comparison
  • Bernstein triangle degree 10 with canonical quadrature matches main at 1,782,525 FLOPs, 22 scalar temporaries, 5 arrays, and 13,454 stored values

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.

Comment thread gem/flop_count.py
@pbrubeck
pbrubeck force-pushed the pbrubeck/optimise-sum-factor branch from 2c0c56f to 43f1be4 Compare August 13, 2026 21:10
pbrubeck and others added 6 commits August 15, 2026 08:31
MappedTabulation contracted each sparse row over a number of entries that
varied with the row. A loop nest cannot express that bound, so the lowering
either failed to build a convex iteration domain or ran every row to the
longest one, and physically mapped elements assembled wrong values.

The transformation is now held in compressed sparse row form and applied as
one rectangular contraction, with rows shorter than the longest padded by
zero coefficients. The basis axis stays a single loop, so the map reaches
the quadrature contraction as a linear map over the reference tabulation.

Literal compared and hashed without its dtype, so an unsigned index literal
and a floating point value literal holding the same number were
interchangeable wherever GEM memoizes on node identity. Literal now
separates them, which Mardal--Tai--Winther needs in order to compile.

Comparison counts as one floating point operation again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compiling the Johnson--Mercier mass-plus-divergence form spends all of its
time in code generation: a cold-cache assembly of the tetrahedral form runs
for 9.5 s and executes in 0.03 s.  Four changes cut the compile from 2.36 s
to 1.77 s without altering a single generated kernel.

Memoize `_contraction_component`.  A loop-ordering search re-plans the same
components once per candidate ordering, so the subset dynamic program ran
33291 times over 2402 distinct arguments.  `Index` compares by identity, so
equal factor tuples share index objects and the plan is reusable.  Its two
per-call `lru_cache` closures become dict memos, which no longer rebuild a
`functools` wrapper on every entry.

Return early from `sum_factorise` when nothing is contracted.  Monomial
collection builds each `rest` through it, always with no contraction indices
and at most two factors, so the planner cost 20% of compilation to form a
product.  Without contraction indices the jagged, constant-index and
distribution branches are all inert and the plan is exactly an association
of the factors.

Add `has_arithmetic`.  Sharing linear maps asks only whether an expression
performs arithmetic, but paid `estimate_cost` for a storage model it
discards; deciding that needs one short-circuiting traversal.

Delete `sort_monomials`.  It reordered a list local to
`find_optimal_atomics`, whose set cover is now solved exactly by branch and
bound, so the result no longer depends on the order in which atomics are
numbered.  The search truncation that would reinstate that dependence is
never reached: the largest observed search visits 67 of 65536 permitted
states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Johnson--Mercier compilation spends most of its time building and comparing
GEM nodes, so two allocations on the hot path cost more than the algebra
around them.  Neither change alters a generated kernel: flop counts are
identical across 24 forms on triangles and tetrahedra, and the assembled
Johnson--Mercier matrix norm is unchanged.

Return the children tuple directly from `_cons_args` when a node carries no
non-child data.  82% of the 295353 calls in a tetrahedral compile come from
`Sum` and `Product`, which built and unpacked two empty generators to
rebuild a tuple they already had.  `_arguments` now hands back `children`
itself, so `is_equal` compares tuples without allocating.

Accumulate monomials onto a plain dict.  `MonomialSum.monomials` defaulted
to `Zero`, so summing onto an absent key constructed a `Zero` for `Sum` to
fold straight back away: 55593 of the 184985 node constructions in a
tetrahedral compile existed only to be discarded.  Reading with `get`
leaves 143.

TSFC compilation of the tetrahedral form drops from 1.77 s to 1.43 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant