Sum-factorisation on simplices - #262
Conversation
0a6c400 to
103092e
Compare
| tensor-product points. | ||
|
|
||
| The evaluation points are the image on the reference cell of the | ||
| tensor-product grid ``eta_pts`` (one ``(-1, 1)`` array per spatial |
There was a problem hiding this comment.
This eta transform is singular
There was a problem hiding this comment.
That's not a problem, because we're only using the code path for collapsed-coordinate quadrature rules that avoid the singularity
| class DuffyElement: | ||
| """Mixin for simplicial elements whose nodal basis coincides with the | ||
| Dubiner expansion set, enabling O(p^d) sum-factorized tabulation on | ||
| collapsed (Duffy) tensor-product point sets. |
There was a problem hiding this comment.
We need the Bernstein extension
There was a problem hiding this comment.
In bernstein.py, we should implement BernsteinExpansionSet (which should implement tabulate_duffy)
| from finat.point_set import CollapsedTensorProductPointSet | ||
|
|
||
|
|
||
| def _element_scale(element): |
| for r, cols in enumerate(rows): | ||
| pad = cols[-1] | ||
| for t in range(k): | ||
| m = cols[t] if t < len(cols) else pad | ||
| row_multiindex[r, t] = raw_multiindices[m] | ||
| row_coeff[r, t] = R[r, m] if t < len(cols) else 0.0 |
8e644a8 to
f872db4
Compare
| dtype=int, count=ndof * dim).reshape(ndof, dim) | ||
|
|
||
|
|
||
| def lexicographic_offsets(dim, n): |
| Dubiner expansion set, enabling O(p^d) sum-factorized tabulation on | ||
| collapsed (Duffy) tensor-product point sets. | ||
| """Mixin enabling O(p^d) sum-factorized tabulation on collapsed (Duffy) | ||
| coordinates, for simplicial elements whose nodal basis coincides with |
There was a problem hiding this comment.
I don't know what "nodal basis coinciding with Dubiner expansion set" means. Does this mean the nodal basis is the Dubiner basis (i.e. the nodes are moments against Dubiner polynomials)?
In this case, the element is unique (up to degree/dimension). But there are other things (e.g. modified C^0 expansion set, Bernstein) where the Duffy transform does a pull-back revealing interesting structure hence sum-factorizations.
There was a problem hiding this comment.
There's are two assumptions that need to be satisfied:
-
The ExpansionSet implements
tabulate_duffyand can be sum factorized, true for the hierarchical ExpansionSet and the new BersteinExpansionSet. -
The matrix of coefficients is sparse. Only true for Legendre/IntegratedLegendre and Bernstein. Legendre and Bernstein should have the identity as the matrix of coefficients, but currently they have a permutation matrix. There are some numbering disagreements between the DualSet and the ExpansionSet that get resolved by entity_ids/coeffs.
Ideally we should just be working in the ordering that favors sum-factorization (lexicographical in the 1D jagged axes) but many parts of FIAT rely on the hierarchical ordering by total polynomial degree. The least invasive and most generic fix was to introduce a gather/scatter permutation for the tabulation and the coefficient that we contract them with.
| where ``phi`` is the raw per-lattice-multi-index tabulation | ||
| `duffy_evaluation` computes. | ||
| """Sparse recombination weights from the raw (continuity=None) | ||
| lattice tabulation into this element's nodal basis: |
There was a problem hiding this comment.
But we said above the nodal basis is Dubiner? Does this mean that what we actually have is the nodal basis is some nice linear combination of the Dubiner basis?
There was a problem hiding this comment.
This is outdated. It should say Duffy-compatible ExpansionSet
| class Bernstein(ScalarFiatElement): | ||
| # TODO: Replace this with a smarter implementation | ||
| def __init__(self, cell, degree): | ||
| class Bernstein(DuffyElement, ScalarFiatElement): |
There was a problem hiding this comment.
This is a remarkably short change to get sum-factorization. I'm assuming (trying to parse) how the functions above get called through the Duffy mixing?
| tensor-product points. | ||
|
|
||
| The evaluation points are the image on the reference cell of the | ||
| tensor-product grid ``eta_pts`` (one ``(-1, 1)`` array per spatial |
There was a problem hiding this comment.
That's not a problem, because we're only using the code path for collapsed-coordinate quadrature rules that avoid the singularity
a5a0c51 to
316c265
Compare
316c265 to
e23bcdb
Compare
041cee8 to
4fa6999
Compare
9248df4 to
87f721b
Compare
87f721b to
4568c68
Compare
The stack head installed here, pbrubeck/coffee-scalar-factor, carries the GEM
changes the TSFC half needs but not the FInAT half of this PR, so the job died
importing Firedrake:
ImportError: cannot import name 'CollapsedTensorProductPointSet'
from 'finat.point_set'
firedrakeproject/fiat#262 sits on top of that head and carries both. Install
it instead.
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
4568c68 to
0c40faf
Compare
A simplex lattice is a triangular index set, but GEM had only rectangular indices. Lowering one either failed to build a convex iteration domain or ran every row out to the longest, so a simplex contraction paid for storage and arithmetic that the mathematics does not need. `gem.JaggedIndex` carries the parent indices that bound it. The node traversers, the interpreter, the flop counter and the Impero scheduler all read the exact domain from those parents, and component tensor value loops are scheduled explicitly rather than left to the consumer. `Literal` compared and hashed without its dtype, so an unsigned index literal and a floating point value holding the same number were interchangeable wherever GEM memoizes on node identity. It now separates them, which Mardal--Tai--Winther needs in order to compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gem.unique built every free_indices tuple with sorted(..., key=id). Index defines neither __eq__ nor __hash__, so this ordered free indices by memory address. Addresses depend on everything the process has allocated, so compiling one form changed the free_indices order seen by the next one, and any pass that branches on that order made a different choice in a warm process than in a cold one. Sorting by Index.count orders by creation instead, which a uniform shift in allocation history preserves. The id sort dates to a5d4050 and affects all of TSFC; it only became visible where sum factorisation picks an index to unflatten by position in free_indices, which made the flop count of a compiled kernel depend on which forms had been compiled before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
simplex_lattice_rank built a degree-d polynomial in the lattice indices, which tsfc inlined into every subscript, so the lattice layout did not survive into the generated code. simplex_lattice_ranks tabulates the rank of each lattice point instead, built from _lattice_points, the same enumeration FlattenedTensor uses to flatten a jagged tensor. The temporary layout and FlattenedTensor now share one definition of the bijection, and the layout stays visible in the AST. compact_index_layout no longer declines to compact a lone lattice. It compacts any simplex component strictly smaller than the box enclosing it, which drops the degenerate one-dimensional case that used to emit t6[11 - ((11 - i) // 1)] for what is just i. compile_gem loses assignment_group_size. It existed to cap the size of an output-shaped temporary that the scheduling change removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
gem/optimise.py had grown to 1870 lines and held four different jobs. Give three of them their own module. gem/jagged.py holds everything about a JaggedIndex lattice: how a product of simplex lattices is compacted into storage, and the rewrites that trade a flat axis for the lattice it enumerates. The lattice combinatorics move out of gem/gem.py, which defines node classes; what stays there is the enumeration itself, which is what a JaggedIndex means, under public names. gem/driver.py holds the pipelines that compose several passes: contraction and unflatten_returns. Those reached gem.coffee and gem.refactorise through imports inside the function body, because a module-level import would have made a cycle. A pipeline sits above the primitives it uses, so from this module the imports are ordinary. contraction ran the same six lines twice, once before unflattening and once after. Run one flatten step twice instead. Every index now answers to `parents`, so the places that asked for it with getattr read the attribute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
0c40faf to
e900247
Compare
TLDR
This PR adds sum-factorised kernels for Bernstein elements on triangles and
tetrahedra. You select them with
dx(scheme="collapsed").A collapsed kernel does far fewer operations. A degree-10 tetrahedral mass
matrix does 100 times fewer. It uses more temporary memory to do so.
Base: #286. Paired Firedrake PR: firedrakeproject/firedrake#5263.
How the loops work
A Bernstein basis on a simplex has a jagged lattice of functions. This PR keeps
that lattice in the compiler, as
JaggedIndexandFlattenedTensor. Thecompiler then contracts one axis at a time.
The compiler must also choose where to put the quadrature loops.
quadrature axis. This is best for squares and cubes.
triangles and tetrahedra.
The compiler builds both loop orders. It measures the memory of each one. Then
it keeps the smaller one. Both orders do the same number of operations, so only
memory decides.
A separate bug in GEM
GEM sorted free indices by memory address. Addresses change when the program
allocates other objects. So the compiler made a different kernel each time you
compiled a different form first. The number of operations was not stable.
GEM now sorts the indices by the order in which it made them.
This bug is from 2016 and it is also on
main. It affects all of TSFC. It is aseparate commit with its own test, so you can review it on its own.
Module layout
gem/optimise.pyhad grown to 1870 lines and held four jobs. This PR givesthree of them their own module.
gem/jagged.pyholds everything about a jagged lattice: how a product ofsimplex lattices is compacted into storage, and the rewrites that trade a flat
axis for the lattice it enumerates.
gem/driver.pyholds the pipelines that compose several passes.gem/cost.pyholds the cost model.gem/gem.pykeeps the node classes and the lattice enumeration, which is what aJaggedIndexmeans.Results
Degree 10 on a tetrahedron. Memory is the total size of the temporary arrays, in
words.
The mass matrix does 100 times fewer operations. The laplacian matrix does 12
times fewer.
The canonical kernel writes into the result directly, so it holds almost
nothing. The collapsed kernel keeps the result of each contraction step. Its
largest array holds 66 x 66 numbers, which is a pair of triangular lattices.
Speed
Assembly time, divided by the canonical time. A larger number is better.
Read this table with care:
more than they save at a low degree.
faster. Memory limits it, not arithmetic.
has a defect at that degree, so the ratio has no meaning.
Tests
make lintis clean.tests/tsfc: 400 pass, 0 fail.firedrake-checkpasses.AI assistance
OpenAI Codex and Claude Code helped to write, test, and measure this work. The
human contributor is responsible for it.