Skip to content

Recognise repeated and joined contractions when sum factorising - #282

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

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

Conversation

@pbrubeck

@pbrubeck pbrubeck commented Aug 19, 2026

Copy link
Copy Markdown

Fixes #283 by recasting sum-factorization as a DP tree traversal with cost growing
with the number of factors as opposed to contraction indices, and falling back to
the original index combinatorial search when there are too many factors.

TLDR: #280 kept an already factorised contraction whole by asking the caller to
pass stop_at. This PR drops that second pass and gets the same result from the
expression itself, by not flattening a contraction that is used more than once
and by planning the product tree instead of searching orderings of the indices.

What changes

  • Flattening a contraction renames its indices apart. That is what makes it
    sound, but it also means a coefficient evaluation used three times becomes
    three independent contractions, and is computed three times. So don't flatten
    a contraction that occurs more than once in the product. Expanding a product
    only pays where factorising the expanded form recovers the sharing that
    expansion broke, which multilinearity guarantees; a product of repeated
    evaluations is not multilinear in them, so there is nothing to recover.

  • Plan the product tree, not the index ordering. Searching orderings has to
    multiply every factor carrying an index before it can reduce that index, so it
    can never reduce over part of a product and multiply the rest in afterwards.
    Build the tree by dynamic programming over subsets of the factors, reducing
    each index at the smallest subtree that holds every factor carrying it. That
    costs 3^factors rather than indices!, and plans the contractions that a
    tensor value index joins together.

  • With the tree planned, dual evaluation no longer needs the inner
    sum_factorise, so only one gem.optimise.contraction remains, and the bound
    on the ordering search stays where it was.

  • Recognising a repeated contraction from the expression retires the stop_at
    argument of contraction and the is_contraction predicate Keep factorised contractions atomic in dual evaluation #280 added for it,
    which nothing passes any more. traverse_product and traverse_sum keep their
    own stop_at, which gem.refactorise still uses.

Interpolation on a hexahedron

Kernel flop counts 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 → vector CG4 23875 23875 14 / 210 / 75 14 / 270 / 75 0.017 s 0.018 s
dot(A, A), tensor CG2 → tensor 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

Every case that raised NotImplementedError: Too many indices for sum factorisation! on the base of #280 still compiles here.

dot(A, A) costs 0.078 s more to compile. It is the one case here whose value
index joins the evaluations into a contraction of seven indices, so it is the
one case that reaches the planner with enough factors to plan.

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. This is the cost of the planner where nothing needed fixing.

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 here is within run to run variation either way. The NCE forms cost
33 flops more at every degree. The planner finds cheaper
trees for three subproblems of those forms, 70 against 90 operations each, but costs
each connected subproblem on its own while the subproblems share terms, so the
cheaper trees drop a shared subexpression. Keeping the whole expression in view
when costing a plan is left for later.

Tests

test_sum_factorise.py covers the bound, now on the ordering search the planner
falls back on; an unrestricted contraction keeping a repeated evaluation whole;
and a contraction joined by a value index into more indices than an ordering
search can take. Each was checked to fail when the change it covers is reverted.

AI declaration: written with Claude Code (Claude Opus 5).

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
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