Skip to content

Recognise repeated and joined contractions when sum factorising - #282

Open
pbrubeck wants to merge 13 commits into
mainfrom
pbrubeck/single-pass-sharing
Open

Recognise repeated and joined contractions when sum factorising#282
pbrubeck wants to merge 13 commits into
mainfrom
pbrubeck/single-pass-sharing

Conversation

@pbrubeck

@pbrubeck pbrubeck commented Aug 19, 2026

Copy link
Copy Markdown

TLDR

Sum factorisation now plans the product tree instead of searching every
order of the contraction indices. It also keeps a contraction whole when the
expression uses it more than once, so the kernel evaluates it once.

Forms that used to fail with Too many indices for sum factorisation! now
compile. Flops fall on the cases that were doing redundant work, and stay equal
everywhere else.

Fixes #283.

What this does

Plan the product tree.
A search over index orders must multiply every factor that carries an index
before it can sum that index away. It can never sum over part of a product and
multiply the rest in later.

This PR builds the tree by dynamic programming over subsets of the factors. It
sums each index at the smallest subtree that holds every factor carrying that
index. The search costs 3^factors instead of indices!. A search over orders
stays as a fallback for very wide products.

Keep a repeated contraction whole.
Breaking a contraction up renames its indices. A coefficient evaluation used
three times therefore becomes three separate contractions, and the kernel
computes it three times. This PR finds such a contraction in the expression and
leaves it alone.

Tidy-up. One helper now holds the rule for what a product-tree walk descends
into. Two walks used to carry their own copy of it.

Interpolation on a hexahedron

Kernel flops and temporaries, as count / entries / largest.

interpolate flops main flops PR temporaries main temporaries PR compile main compile PR
f*f*f, CG1 -> CG1 96 96 9 / 11 / 2 9 / 13 / 2 0.011 s 0.011 s
f*f*f*f, CG1 -> CG1 104 104 9 / 11 / 2 9 / 13 / 2 0.011 s 0.011 s
f*f, CG4 -> DG3 2568 2568 4 / 51 / 25 4 / 66 / 25 0.008 s 0.009 s
dot(u, u)*u, vector CG4 23875 23875 14 / 210 / 75 14 / 270 / 75 0.017 s 0.018 s
dot(A, A), tensor CG2 -> CG3 16776 16776 81 / 240 / 4 81 / 240 / 4 0.089 s 0.163 s
inner(A, A), tensor CG2 -> CG2 4131 4131 120 / 126 / 3 120 / 180 / 3 0.126 s 0.129 s
grad(f)[0], CG4 -> CG4 14106 13981 92 / 292 / 25 91 / 375 / 25 0.066 s 0.066 s
div(u), vector CG4 -> DG3 23548 23100 110 / 391 / 25 109 / 543 / 25 0.080 s 0.077 s

dot(A, A) costs 0.078 s more to compile. Its value index joins the evaluations
into a contraction of seven indices. It is the only case here wide enough for
the planner to do real work.

Helmholtz on an extruded hexahedral mesh

inner(u, v)*dx + inner(d(u), d(v))*dx, with d = grad for CG and curl for
NCE. These forms need no repair, so this table shows what the planner costs.

flops main flops PR entries main entries PR compile main compile PR
CG1 14732 14732 1153 1171 0.120 s 0.126 s
CG3 611668 611668 4460 4502 0.105 s 0.103 s
CG7 37651332 37651332 90392 90482 0.101 s 0.104 s
NCE1 136618 136651 7151 7178 1.028 s 1.003 s
NCE3 4486030 4486063 22058 22109 0.754 s 0.758 s
NCE7 282933862 282933895 612438 612537 0.752 s 0.755 s

Compile time moves less than run-to-run noise. The NCE forms cost 33 more flops
at every degree. The planner finds cheaper trees for three parts of those forms,
70 operations against 90 each. It costs each connected part on its own, so it
does not see that the parts share terms, and it drops one shared subexpression.
Costing the whole expression at once is future work.

Tests

test_sum_factorise.py covers the fallback bound, a contraction that keeps a
repeated evaluation whole, and a contraction joined by a value index into more
indices than an order search can take. Each test was checked to fail when its
change is reverted.

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.

Comment thread gem/optimise.py Outdated
subtree holding every factor that carries it, which is the earliest
its reduction is legal. Unlike a search over orderings of the
indices, this can reduce an index over part of the product and
multiply the rest in afterwards.

@pbrubeck pbrubeck Aug 19, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is the algorithmic realisation of splitting sum-factorization into subsets of factors. This models the splitting in dual_evaluation.

Before, we were tackling all indices at once and searching over all possible permutations = indices!.

Now we traverse a tree, by appending factors one by one. The cost is reduced to 3^factors.

Base automatically changed from pbrubeck/atomic-contraction to main August 19, 2026 14:27
@pbrubeck
pbrubeck force-pushed the pbrubeck/single-pass-sharing branch from ff49423 to 8c90307 Compare August 19, 2026 14:45
pbrubeck added a commit to firedrakeproject/firedrake that referenced this pull request Aug 20, 2026
The TSFC changes here need the GEM changes in the FIAT stack
firedrakeproject/fiat#282 -> #284 -> #281, whose head carries all three.
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 added a commit to firedrakeproject/firedrake that referenced this pull request Aug 22, 2026
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>
Comment thread finat/finiteelementbase.py Outdated
# Factorise over the new contraction with Qi, keeping whole the
# contractions that fn already factorised
evaluation = gem.optimise.contraction(evaluation, stop_at=is_contraction)
evaluation = gem.optimise.contraction(evaluation)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Suggested change
evaluation = gem.optimise.contraction(evaluation)

Comment thread finat/tensorfiniteelement.py Outdated
# a minimal memory footprint, although the operation count
# does appear to be minimal.
evaluation = gem.optimise.contraction(evaluation, stop_at=is_contraction)
evaluation = gem.optimise.contraction(evaluation)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Suggested change
evaluation = gem.optimise.contraction(evaluation)

pbrubeck and others added 10 commits August 26, 2026 12:36
Coefficient evaluations reach FInAT's dual evaluation already sum
factorised by TSFC.  Flattening them back into the surrounding
contraction discards that factorisation, along with the subexpressions
the factors share, and multiplies the indices to search over: a product
of a few evaluations, or evaluations coupled through a value index, then
exceeds what one exhaustive search can handle.

Pass the new gem.optimise.is_contraction predicate as stop_at, so that
traverse_product keeps each factorised contraction whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the inner sum_factorise from dual evaluation so only the outer
gem.optimise.contraction remains, and raise the index limit that one
pass now has to cover.
Flattening a contraction renames its indices apart, so a coefficient
evaluation used more than once in a product becomes that many
independent contractions and is evaluated once per use.  Count the
occurrences of each contraction in the product tree and keep whole the
ones that occur more than once.

Expanding a product only pays where factorising the expanded form
eliminates the sharing the expansion introduced, which multilinearity
guarantees; a product of repeated evaluations is not multilinear in
them, so expanding it can only lose sharing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Searching orderings of the contraction indices has to multiply every
factor carrying an index before it can reduce that index, so it cannot
reduce an index over part of a product and multiply the rest in after.
Plan the product tree instead, by dynamic programming over subsets of
the factors, reducing each index at the smallest subtree that holds
every factor carrying it.

This costs 3^factors rather than indices factorial, and plans the
contractions a tensor value index joins together without the ordering
search blowing up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sets of indices iterate in hash order, so the reduction order, and with
it the generated kernel, varied between runs and broke idempotency.
Break the ties on index count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ranking plans by storage as well as operations, whether as a tiebreak
or added into one score, selects exactly the same plans: the storage a
kernel declares follows from the loop nest the schedule builds, which
a cost over the expression alone cannot see.

Planning the product tree also leaves the ordering search unreachable
for these contractions, so its limit goes back to six.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bound on a single connected contraction now applies to the ordering
search the planner falls back on, and an unrestricted contraction keeps
a repeated evaluation whole by itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing passes it now that a repeated contraction is recognised from
the expression itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bound on the factors a product tree is planned for sat in a module
global while the bound on the indices an ordering search takes is a
literal at its test.  Put them together, and write the two docstrings
this file gained in the :arg: style the rest of it uses.

Cover a connected contraction of more factors than the planner takes but
few enough indices for the ordering search, which is the shape that keeps
both bounds live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pbrubeck
pbrubeck force-pushed the pbrubeck/single-pass-sharing branch from 951b80a to e218260 Compare August 26, 2026 11:42
pbrubeck and others added 3 commits August 29, 2026 15:41
repeated_contractions re-implemented the rule that traverse_product
already encodes, and dropped its treatment of a reciprocal.  Put the rule
in _product_descent and let both walks use it.

Name the factor bound _MAX_PLANNED_FACTORS and keep the reasoning for its
value beside the number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
…sharing

main gained a greedy fallback for a connected contraction too large to
search (#292), while this branch plans a product tree for one with few
enough factors.  Keep both: plan when the factor count allows it, and let
the ordering search below that fall back on the greedy order rather than
raising.

`test_too_many_indices_in_one_contraction` asserted the NotImplementedError
that #292 removed, and #292's own `test_greedy_contraction` builds two
factors, which the planner now takes before the greedy path can see it.
They become one test over the shape that still reaches greedy: more factors
than the planner takes, and more indices than the search can afford.

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

Enhance sum-factorization

1 participant