Skip to content

Factor scalar maps through basis transformations - #286

Open
pbrubeck wants to merge 15 commits into
pbrubeck/zany-matvecfrom
pbrubeck/coffee-scalar-factor
Open

Factor scalar maps through basis transformations#286
pbrubeck wants to merge 15 commits into
pbrubeck/zany-matvecfrom
pbrubeck/coffee-scalar-factor

Conversation

@pbrubeck

@pbrubeck pbrubeck commented Aug 21, 2026

Copy link
Copy Markdown

TLDR

A padded basis transformation stores the same coefficient many times, and writes
each copy into the kernel as its own instruction. This PR stores each distinct
coefficient once
and looks the rest up through a small table.

It also factors a reduction through a repeated indirect lookup, so the
kernel sums over the reference table once and then gathers.

Johnson--Mercier on tetrahedra drops from 1,993 lines of C to 358, and its cold
C build time falls 68%. Flops fall on the elements this PR touches.

Base: #281. Needs firedrakeproject/firedrake#5362.

What this does

Intern equal coefficients. MappedTabulation collects the distinct symbolic
coefficients of the transformation. Two read-only tables then pick the reference
column and the coefficient for each padded entry. The 3D Guzman--Neilan matrix
no longer holds a mutable 16-by-9 coefficient matrix, and its distinct
coefficient workspace has length 43.

Give an additive map an order-free key. COFFEE keys an additive linear map
on its set of summands, not on the order they were built in. It then moves a
uniform multiplicity out into the scalar part of the monomial. A diagonal strain
component simplifies from 0.5 (Gii + Gii) to Gii. An off-diagonal component
keeps its multiplicity.

Factor a reduction through an indirect lookup. For an indirect column c(i),
linearity gives

sum_q B[c(i), q] f[q] = (sum_q B[r, q] f[q])[c(i)]

The kernel computes a dense reference residual once per table row, then gathers
it through the padded map. A cost model decides when this is worth doing.

Factor common scalars, but only inside coefficients. Sums that carry free
indices stay with the contraction planner.

Count an empty contraction. A contraction of an empty product contributes the
size of its index space. This is the identity sum_i 1 = dim(i). It comes up
when a selector makes one part of a contraction independent of an index.

Nothing here branches on element family, cell dimension, degree or form operator.

Benchmarks

Measured on this branch against main. The forms and columns are the same as
in #281.

Zany elements, bilinear form

element dim flops array temps entries largest AST lines tsfc (s) build (s) kernel (s)
Argyris 2 38,663 -> 42,738 6 -> 8 126 -> 197 21 -> 50 431 -> 203 0.206 -> 0.279 2.63 -> 2.08 0.000041 -> 0.000048
Guzman--Neilan 2 10,041 -> 10,963 8 -> 11 72 -> 105 9 -> 15 372 -> 162 0.157 -> 0.143 1.52 -> 0.94 0.000011 -> 0.000014
Guzman--Neilan 3 384,767 -> 344,078 18 -> 20 288 -> 347 16 -> 43 1,803 -> 440 0.810 -> 0.696 6.69 -> 2.58 0.001689 -> 0.001458
Johnson--Mercier 2 21,660 -> 21,645 14 -> 16 630 -> 653 225 454 -> 182 0.233 -> 0.269 1.80 -> 1.03 0.000023 -> 0.000026
Johnson--Mercier 3 533,683 -> 488,173 26 -> 30 4,536 -> 4,676 1,764 1,993 -> 358 1.000 -> 1.538 9.83 -> 3.16 0.001538 -> 0.001638

Zany elements, matrix-free action

element dim flops array temps entries largest AST lines tsfc (s) build (s) kernel (s)
Argyris 2 6,632 -> 10,095 3 -> 7 63 -> 176 21 -> 50 427 -> 290 0.233 -> 0.255 2.61 -> 2.30 0.000006 -> 0.000014
Guzman--Neilan 2 3,549 -> 4,431 4 -> 5 36 -> 111 9 -> 36 343 -> 273 0.159 -> 0.178 1.42 -> 1.11 0.000004 -> 0.000008
Guzman--Neilan 3 106,236 -> 147,180 9 -> 7 144 -> 547 16 -> 144 1,559 -> 853 0.734 -> 0.834 5.52 -> 3.43 0.000976 -> 0.001006
Johnson--Mercier 2 3,753 -> 5,481 9 -> 7 135 -> 98 15 444 -> 250 0.267 -> 0.291 1.75 -> 1.18 0.000005 -> 0.000007
Johnson--Mercier 3 35,992 -> 71,260 17 -> 10 714 -> 392 42 1,911 -> 670 1.241 -> 1.051 10.02 -> 3.22 0.000205 -> 0.000386

Interning removes real arithmetic where the map has repeated coefficients:
Guzman--Neilan in 3D falls 10.6%, Johnson--Mercier falls 0.1% in 2D and 8.5% in
3D. AST lines fall further than in #281: Argyris to 203 lines, 52.9% below
main; Guzman--Neilan in 3D to 440, down 75.6%; Johnson--Mercier in 3D to 358,
down 82.0%. The isolated build time improves in every matrix and action case,
by 12-68%.

The action still costs more arithmetic than main, up to +98.0% for
Johnson--Mercier in 3D. An action has no second argument axis to share
coefficients across, so interning has nothing to remove there.

Elements this PR does not touch

element degree dim flops array temps entries AST lines tsfc (s)
CG 3 3 49,592 5 860 169 0.061 -> 0.069
RT 5 3 10,384,508 -> 7,865,477 6 29,280 168 -> 142 0.097 -> 0.097
Q 7 3 16,034,499 31 -> 37 87,199 -> 87,253 394 0.127 -> 0.141
NCE 7 3 115,963,956 -> 115,963,989 239 -> 245 604,385 -> 604,439 1,962 -> 1,980 0.833 -> 1.180

Q and NCE flops do not change. Interning only touches the scalar coefficients
inside a basis-transformation matrix, and Q and NCE have none.

NCE compile time rises 32-66%. This is the one measured cost on a case the PR
does not otherwise touch. The new cost comparison walks NCE's much larger
expression DAG and finds nothing to rewrite.

Bernstein control

Degree-4 Bernstein Laplacians are structurally identical to main.

cell flops main flops PR scalar temps array temps stored values AST lines
triangle 11,965 11,965 22 5 424 82
tetrahedron 181,454 181,454 47 7 2,590 135

Validation

  • focused FIAT and GEM tests: 135 passed
  • make lint
  • resource-bounded, single-threaded code generation and cold-cache Firedrake
    benchmarks

AI assistance

OpenAI Codex and Claude Code were used for implementation, benchmarking, and
drafting this section. The human contributor remains responsible for
understanding, validating, and maintaining the changes.


Update 2026-09-08: the cost model was declining every zany element

Cost the gathers that share a tabulation together. For an indirect column
c(i), linearity gives

sum_q B[c(i), q] f[q] = (sum_q B[r, q] f[q])[c(i)]

so the kernel can compute a dense reference residual once per table row and
gather it through the padded map. A cost model decides when that is worth doing.

The model has to weigh the gathers of one tabulation together, not one at a
time. A padded transformation reads its reference tabulation through one gather
per padded column, and each of those gathers selects exactly as many rows as it
has arguments -- Johnson--Mercier in 3D has 42 rows and an argument extent of 42
-- so every gather scored a saving of zero on its own and the rewrite never
fired on the elements it was written for. The saving is now
sharing * iteration_count - nrows.

The gathers also have to agree on the row index. Index(extent=nrows) was
minted fresh per rewrite, so three tabulations that are otherwise identical
could never be hash-consed into one. There is now one row index per
(arguments, nrows).

Both halves are needed: test_gathers_sharing_a_table_tabulate_it_once goes red
if either is reverted on its own.

This takes the Johnson--Mercier 3D action from 60,358 flops to 32,806,
against 44,248 on main, and the Argyris 2D action from 6,962 to 3,938,
against 6,508. Guzman--Neilan already fired and is unchanged. Bilinear forms are
untouched: the rewrite only pays where a coefficient has already collapsed the
quadrature axis.

Benchmarks

Measured 2026-09-08 against today's main, with the whole stack installed
(fiat#282 -> #284 -> #281 -> #286 paired with the two firedrake PRs). Minimum of
25 timed assemblies, three interleaved repetitions. NCE degree 3 on a prism is
the control: the stack cannot touch it, and it is flat, so the harness is
honest.

Action

element dim flops main flops stack assemble main assemble stack
Argyris 2 6,508 3,938 0.890 ms 0.796 ms 11% faster
Johnson--Mercier 2 3,753 3,129 1.151 ms 1.151 ms unchanged
Johnson--Mercier 3 44,248 32,806 8.348 ms 10.191 ms 22% slower
Guzman--Neilan 2 3,549 2,586 1.044 ms 0.965 ms 8% faster
Guzman--Neilan 3 106,236 51,296 26.882 ms 7.806 ms 3.4x faster
NCE degree 3 (control) 3 110,019 110,052 1.608 ms 1.621 ms 1.01

Bilinear form

element dim flops main flops stack assemble main assemble stack
Argyris 2 38,611 53,286 3.532 ms 4.053 ms 15% slower
Johnson--Mercier 2 21,660 21,645 3.384 ms 3.543 ms 5% slower
Johnson--Mercier 3 686,329 494,893 53.466 ms 50.410 ms 6% faster
Guzman--Neilan 2 10,041 10,963 2.300 ms 2.506 ms 9% slower
Guzman--Neilan 3 384,767 344,078 47.364 ms 43.073 ms 9% faster
NCE degree 3 (control) 3 1,639,009 1,639,042 27.664 ms 27.733 ms 1.00

The assembled action vectors agree with main to 12 digits on Johnson--Mercier
and Guzman--Neilan in 2D and 3D, so the Guzman--Neilan win is real work and not
dropped work.

Generated code

The stack replaces unrolled scalar arithmetic with tables and loops, so it moves
storage out of working temporaries and into read-only tables. Working
temporaries are the ones that matter, and they fall.

element dim working elements main working stack C lines main C lines stack
Argyris (action) 2 63 113 387 352
Johnson--Mercier (action) 3 798 224 2,127 771
Johnson--Mercier (matrix) 3 4,704 4,676 2,315 364
Guzman--Neilan (action) 3 144 91 1,559 851
Guzman--Neilan (matrix) 3 288 347 1,803 432

Validation of the update

  • FIAT test/gem and test/finat: 381 passed, 8 skipped
  • make srclint, make doclint
  • firedrake tests/tsfc paired with this branch: 386 passed
  • the zany and interpolation regression subset in Place indirect contractions once per assignment firedrake#5438:
    235 passed, 57 deselected
  • assembled action vectors bit-identical to main to 12 digits on
    Johnson--Mercier and Guzman--Neilan, 2D and 3D

pbrubeck and others added 12 commits August 29, 2026 15:45
optimise_monomial_sum runs inside the recursive sum_factorise, so calling
the reduction placement there walked the same subtrees once per recursion
level: 754 calls over 75,497 nodes for a Johnson-Mercier 3D matrix, which
the pass never improves.  Leave the traversal to the caller, memoise it on
the node rather than its id, and skip costing an IndexSum that offers no
indirect gather to factor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sharing the result between structurally equal subexpressions, as the
other rewrites in this module do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tie in _plan_contraction ranks indices by their position in
sum_indices, which spectral.py fixes to quadrature source order and
_independent_contractions preserves into each subproblem.  A plan
therefore depends only on the contraction it is planning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Indexed(ComponentTensor(Indexed(C, kk), jj), ii) rewrites kk in place, which
only reaches an index that appears in kk as itself.  An index reached through
a VariableIndex is a free index of the lookup, not a key of kk, so the rewrite
left it behind while the caller went on believing it had been renamed.

That is how make_renamer separates two sums that bind the same index, so the
two sums stayed joined:

    (sum_k a_k) * (sum_k b_k)  ==>  sum_k a_k b_k

Nothing exercised this until a basis transformation became a gather through a
lookup table.  Assembling a diagonal indexes both arguments with the test
index, so a mapped tabulation meets itself in a product, and the shared row
index turned the square of a sum into a sum of squares.  The matrix-free
diagonal of an HCT-red biharmonic form came out 5.4 times too large, and the
multigrid built on it took 20 iterations where 16 were expected.

Leave the pattern alone when jj is hidden in a VariableIndex, so that
replace_indices, which does substitute inside the lookup, handles it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
inline_temporaries now offers a ComponentTensor over an IndexSum for inlining,
so that a consumer can index straight through the view rather than read a
temporary.  Only Indexed can do that: loopy lowers Indexed(ComponentTensor(e))
by reusing the temporary behind e.

Inverse, Solve and FlexiblyIndexed read the tensor whole, and a ComponentTensor
carries no name to give them.  Slate reaches all three -- A.solve(A*C) puts a
matrix-vector product under a Solve -- and dropping its Evaluate left codegen
with a node it cannot name:

    AttributeError: 'ComponentTensor' object has no attribute 'name'
    AssertionError: cannot generate expression from gem.gem.ComponentTensor

That accounted for 42 failures across tests/firedrake/slate.  Keep a reduction
view inlinable only where an Indexed consumes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
make_sum takes a sequence of summands.  Passing a Counter worked only
because iterating a Counter yields its keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NoTXWyj2fVdJTHnMDFB4k
@pbrubeck
pbrubeck force-pushed the pbrubeck/coffee-scalar-factor branch from 4e650b6 to 9941617 Compare August 29, 2026 15:21
@pbrubeck
pbrubeck marked this pull request as ready for review September 5, 2026 04:34
pbrubeck and others added 2 commits September 8, 2026 18:24
A padded basis transformation reads one reference tabulation through a map
per padded column, so the same contraction runs once per column.  Costing
each gather on its own missed that: a gather that selects as many rows as it
has arguments saves nothing alone, and every gather of a zany element's
transformation is of that shape, so the contraction was never tabulated.

Cost the gathers that read one tabulation together, and give them one row
index, so that they build the same node and evaluate it once.  This takes
the Johnson-Mercier 3D action from 60,358 flops to 32,806, against 44,248 on
main, and the Argyris 2D action from 6,962 to 3,938 against 6,508.

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.

1 participant