Skip to content

Optimize tensor contractions and cell geometry evaluation - #919

Open
garth-wells wants to merge 53 commits into
mainfrom
perf/elasticity-kernel-contractions
Open

garth-wells wants to merge 53 commits into
mainfrom
perf/elasticity-kernel-contractions

Conversation

@garth-wells

@garth-wells garth-wells commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Generated kernels repeat basis contractions and geometry calculations, and blocked vector forms update interleaved output entries at every quadrature point. This change reduces that work and accumulates eligible component blocks locally before adding them to the element tensor. The existing kernel interface and accumulation into nonzero A are preserved.

The implementation:

  • Preintegrates linear and bilinear blocks with cellwise-constant scalar factors, merges reference tensors sharing a factor and output map, and flattens eligible contiguous contractions. Reference tables have a 64 KiB limit; partial preintegration retains quadrature for the remaining blocks.
  • Accumulates eligible point-varying component blocks in bounded local arrays with contiguous trial-index loops and zero-padded tables. One final pass interleaves component rows and adds them to A. Small or unsupported contractions retain their existing layout.
  • Proves block equality, transposed equality and symmetry from factor values and argument-table mappings. Equivalent blocks reuse an existing local array. Eligible symmetric diagonal blocks accumulate only on and above SIMD-sized diagonal groups, then mirror the omitted entries. Short unsplit blocks retain their update order.
  • Evaluates eligible coefficient and geometry expressions across quadrature points using point-major table copies and a bounded intermediate cache. Point padding is limited to 25%; contractions use only the original quadrature points. Cheap evaluations and unsupported cases remain fused.
  • Factors scalar arithmetic, shares reciprocals and geometry-weight scaling, collapses point-independent quadrature, and prunes unused scalars and tables. Coordinate reductions combine zero/sign-paired columns at tabulation round-off precision. Dense coefficient reductions of at most eight dofs are expanded selectively on varying geometry.
  • Explains these stages, local block layouts and padded loops in generated comments. Shared section descriptions cover C and Numba. Literal weighted factors on point facets and chained block equivalences have regression coverage.
  • On quadrilaterals and hexahedra, keeps sum-factorised stage and row-accumulation loops rolled when they fill whole SIMD vectors, padding per-direction point storage to a vector multiple. Point loops whose accesses all use the flattened point index become one loop over all points, so geometry evaluation vectorises.
  • Moves library math calls (for example log) out of the quadrature evaluation loop into separate argument, call and result loops, so the remaining evaluation vectorises without -ffast-math.
  • Sum-factorises Lagrange and discontinuous Lagrange elements from basix.ufl.element on quadrilaterals and hexahedra, not only elements with tensor-product dof ordering. The 1D factors come from the tensor-product-ordered element of the same family and variants; a permutation, found by matching basis functions, maps each tensor-product index to the element's dof. The kernel gathers coefficient and coordinate dofs into tensor-product order and writes A through a static dof map, so its interface and the element's dof numbering are unchanged. Ordinary Q1 meshes now sum-factorise their geometry.
  • Makes sum_factorization a choice of "auto" (the new default), "always" and "never". With "auto", a bilinear form uses ordinary quadrature when sum factorisation is estimated to cut the multiply-adds contracting the argument tables by less than the new sum_factorization_min_speedup option (default 1.33). This keeps low-order forms with several terms, such as Q1 Poisson on quadrilaterals, on the faster ordinary path. Linear forms are always sum-factorised. "always" sum-factorises wherever it is supported, whatever the estimate; other values, including True and False, raise an error.
  • In sum-factorised stage contractions, takes out argument tables that are constant across a direction's points, such as degree 1 derivatives: the integrand is summed once against the weights and the other tables, for each of their dofs, and the constant tables scale the result where it is read, instead of the sum being repeated for each dof of the constant arguments.
  • Marks loops that Clang would otherwise vectorise across long inner loops, or across strided updates of A, to stay scalar. The hint is empty for other compilers.

These transformations use compiler auto-vectorisation and reassociate floating-point arithmetic. Selection rules and storage budgets limit the extra local arrays, cached factors and loop padding.

Performance comparisons

All times below are nanoseconds per cell, including output zeroing; lower is better. Speedup is baseline time divided by PR time. Kernels and drivers are separate translation units without LTO. Measurements use logical CPU 2 of an Intel Xeon w7-3445, GCC 16.0.1 (experimental r16-8246) and Clang 22.1.2. Hot-cache and DRAM-streaming results are reported separately. All tables were re-timed on 31 October 2026, using PR revision 4a6aa405 except the quadrilateral and hexahedral table, which uses 719b29ea.

The baselines are current main, feb3cc41 (the merge base, including sum factorisation), and the latest release, v0.11.0. PR and main use Basix 0.12.0.dev0 and UFL 2026.3.0.dev0; the release uses Basix 0.11.0 and UFL 2026.1.0. Each revision uses its own UFCx header. Every tensor entry on all 32 benchmark cells is checked across variants before timing.

DRAM-streaming improvement

Each cell owns a distinct output tensor, coordinates and coefficients in a 64-byte-aligned record. Each variant sweeps a 1 GiB allocation; seven interleaved samples are taken after initialisation and warm-up. Flags are -O3 -march=native -ffp-contract=fast -ffast-math, with -shared -fPIC and -lm. Quadrature, coordinates and coefficients are identical across variants.

The table compares v0.11.0, an earlier PR revision 30757e95 (before) and the current PR. The before revision already includes local block accumulation and point-major evaluation; “vs before” isolates the later row blocking, symmetric-diagonal split, library-call isolation and Clang loop hints.

Kernel GCC v0.11.0 GCC before GCC PR vs before vs release Clang v0.11.0 Clang before Clang PR vs before vs release
Linear elasticity P2 2,110.8 1,029.5 909.6 1.13× 2.32× 3,968.8 1,229.4 921.2 1.33× 4.31×
Linear elasticity P3 18,817.4 3,413.3 3,203.6 1.07× 5.87× 40,726.7 3,529.6 3,459.4 1.02× 11.77×
Hyperelastic residual P2 453.3 160.9 141.5 1.14× 3.20× 275.7 172.3 155.2 1.11× 1.78×
Hyperelastic residual P3 1,180.0 477.3 432.6 1.10× 2.73× 1,230.4 500.7 498.7 1.00× 2.47×
Hyperelastic tangent P2 5,974.0 2,382.9 2,269.9 1.05× 2.63× 9,880.6 2,123.5 2,050.4 1.04× 4.82×
Hyperelastic tangent P3 32,406.3 11,060.3 9,397.8 1.18× 3.45× 62,648.3 9,950.0 9,035.3 1.10× 6.93×

The v0.11.0 P3 elasticity kernel is very large, and its time varies by 20–40% between runs, so its “vs release” ratios are approximate. These are single-core local tensor assembly measurements; global assembly and socket-wide peak bandwidth are outside their scope.

Hot-cache elasticity and hyperelasticity versus main and release

These runs cycle through 32 affine tetrahedra and reuse a local output tensor. They use equispaced vector Lagrange P1–P3 elements, isotropic linear elasticity and compressible neo-Hookean residuals/tangents with mu = 1 and lambda = 1.25. All variants share the same prescribed quadrature. Flags are -O3 -march=native -ffp-contract=fast, without -ffast-math. Each value is the minimum over two runs of nine approximately 0.1-second samples; variant order rotates between samples. Clang P3 times varied by up to 35% between runs with identical kernel sources.

GCC 16

Kernel v0.11.0 (ns) main (ns) PR (ns) Speedup vs main Speedup vs release
Linear elasticity P1 290.26 139.18 90.21 1.54× 3.22×
Linear elasticity P2 3,095.78 1,132.55 398.64 2.84× 7.77×
Linear elasticity P3 25,561.90 8,282.84 1,466.66 5.65× 17.43×
Hyperelastic residual P1 80.58 67.04 33.16 2.02× 2.43×
Hyperelastic residual P2 642.27 669.67 166.05 4.03× 3.87×
Hyperelastic residual P3 1,940.78 1,984.28 472.36 4.20× 4.11×
Hyperelastic tangent P1 1,052.32 464.57 124.94 3.72× 8.42×
Hyperelastic tangent P2 10,377.83 4,533.52 1,723.16 2.63× 6.02×
Hyperelastic tangent P3 58,156.04 20,740.95 7,333.34 2.83× 7.93×

Clang 22

Kernel v0.11.0 (ns) main (ns) PR (ns) Speedup vs main Speedup vs release
Linear elasticity P1 203.32 128.19 73.59 1.74× 2.76×
Linear elasticity P2 3,025.73 2,086.52 362.24 5.76× 8.35×
Linear elasticity P3 24,481.11 8,790.33 1,658.68 5.30× 14.76×
Hyperelastic residual P1 60.15 52.36 32.01 1.64× 1.88×
Hyperelastic residual P2 487.51 495.46 154.60 3.20× 3.15×
Hyperelastic residual P3 1,421.31 1,455.18 476.70 3.05× 2.98×
Hyperelastic tangent P1 849.54 426.22 115.94 3.68× 7.33×
Hyperelastic tangent P2 9,812.25 7,550.51 1,704.10 4.43× 5.76×
Hyperelastic tangent P3 57,220.31 22,823.94 8,472.33 2.69× 6.75×

Hot-cache Poisson samples versus main and release

The same compiler flags and timing method apply, with one nine-sample run. These affine forms use each revision's default quadrature, so release comparisons include dependency and quadrature-selection differences.

Form GCC release GCC main GCC PR Clang release Clang main Clang PR
Tri P2 Poisson 35.94 37.56 9.52 55.43 26.84 9.01
Tri P3 Poisson 139.40 98.88 30.68 176.16 310.90 28.41
Tet P2 Poisson 227.93 110.49 59.20 203.00 236.31 59.28
Tet P3 Poisson 1,819.40 615.07 153.46 2,830.82 1,902.62 144.52

Hot-cache quadrilateral and hexahedral samples versus main and release

The same method applies to equispaced Lagrange Q1–Q3 on affine quadrilaterals and Q1–Q2 on affine hexahedra (which FFCx does not treat as affine), with each revision's default quadrature. These elements are built with basix.ufl.element, so main does not sum-factorise them. The PR sum-factorises all of them except the quadrilateral Q1 Poisson forms, for which the cost estimate selects ordinary quadrature. The weighted forms multiply by a coefficient in the same space.

Form GCC release GCC main GCC PR vs main Clang release Clang main Clang PR vs main
Quad Q1 mass 64.8 64.5 10.5 6.16× 16.5 16.9 12.5 1.36×
Quad Q1 Poisson 72.0 84.2 42.4 1.98× 73.3 87.1 42.6 2.05×
Quad Q1 weighted Poisson 80.4 87.2 45.6 1.91× 84.8 100.7 43.9 2.29×
Quad Q2 mass 148.5 147.5 69.3 2.13× 148.2 148.7 76.1 1.95×
Quad Q2 Poisson 471.5 387.8 197.7 1.96× 462.7 751.9 192.9 3.90×
Quad Q2 weighted Poisson 755.7 648.3 336.3 1.93× 759.1 1,203.0 355.7 3.38×
Quad Q3 mass 567.5 577.5 188.5 3.06× 728.1 739.8 178.7 4.14×
Quad Q3 Poisson 1,255.0 985.0 458.0 2.15× 1,159.6 2,923.7 451.4 6.48×
Quad Q3 weighted Poisson 1,931.3 1,586.3 597.4 2.66× 1,794.1 3,921.6 625.1 6.27×
Hex Q1 mass 505.1 370.9 93.7 3.96× 377.3 399.4 92.6 4.31×
Hex Q1 Poisson 1,447.4 936.8 449.2 2.09× 1,164.1 1,091.5 382.9 2.85×
Hex Q1 weighted Poisson 3,421.8 2,366.7 564.3 4.19× 2,810.9 2,711.8 590.3 4.59×
Hex Q2 mass 6,245.4 5,997.8 570.7 10.51× 4,917.6 4,995.5 537.3 9.30×
Hex Q2 Poisson 20,644.1 7,747.9 2,872.8 2.70× 27,068.9 7,968.9 2,851.9 2.79×
Hex Q2 weighted Poisson 39,932.6 15,077.4 6,759.9 2.23× 52,962.5 16,693.6 6,049.5 2.76×

The PR is faster than main on every case here: 1.4–10.5×. Q2/Q3 kernels are 1.9–10.5× faster. Under Clang, the quadrilateral Q2/Q3 Poisson kernels are now 2.1–2.9× faster than v0.11.0, where main is 1.6–2.5× slower than it.

The cost estimate does not always choose the faster path. GCC hex Q1 Poisson is 0.80× the speed of ordinary quadrature when sum-factorised (449 ns against 358 ns), while under Clang it is 2.3× faster, so it stays sum-factorised. Forcing either path for every form (75 quadrilateral and hexahedral forms, both compilers) showed bilinear forms below the 1.33 threshold were 13–29% slower when sum-factorised, and nearly all above it were faster; the exception nearest the threshold, quadrilateral Q1 reaction–diffusion (estimate 1.46), is 4–18% slower. With the shortest exact quadrature rules, Q1 Laplace on hexahedra and Q1 elasticity on quadrilaterals are estimated below the threshold but are 1.1–2.7× faster sum-factorised, while Q1 elasticity on hexahedra, with a similar estimate, is 2.0–2.5× faster with ordinary quadrature. Linear forms are neutral on average when sum-factorised, with losses of up to 30% on a few small kernels, and are up to 6.9× faster with a coefficient to evaluate.

Validation

Benchmark checks compare every tensor entry on all 32 cells against independent quadrature or the other variants. Tests cover nonzero output accumulation, signed custom quadrature, rectangular and blocked tensors, real/complex single/double precision, curved simplex and warped tensor-product geometry, H(div)/H(curl) maps, literal point-facet factors, interior-facet block-equivalence chains, rolled sum-factorised loops, flattened point loops, sum factorisation of entity-ordered elements at degrees 1–3 and the cost-based choice of path. Hyperelasticity tests include the undeformed linear limit, tangent symmetry and finite differences.

The results are specific to the tested cases, compilers and flags; they do not establish a universal no-regression guarantee.

🤖 Generated with Claude Code

@garth-wells garth-wells changed the title Optimize scalar contractions and affine-cell tensor assembly Optimize tensor contractions and cell geometry evaluation Oct 17, 2026
@garth-wells
garth-wells marked this pull request as draft October 18, 2026 14:51
garth-wells and others added 11 commits October 18, 2026 14:57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For point-varying contractions that update three or more component blocks
of the element tensor, accumulate each block in a zero-initialised local
array with the trial index innermost and contiguous, padding the loop to a
32-byte multiple with zero-padded tables. A single pass after the
quadrature loop adds the blocks into A. This removes the strided,
interleaved updates of A at every quadrature point and moves the
arithmetic ahead of the first access to A.

Blocks whose terms equal those of an earlier block, or its transpose with
transposed entry maps, are not computed. Factors are compared through
value ids that expand small point-varying polynomials, so equal values
built in different orders are recognised. Symmetric tangents compute six
of nine 3D blocks.

In the split evaluation loop, read element tables through point-major
copies so coefficient and geometry evaluation is contiguous across points,
and run the loop to a SIMD multiple of points when this adds at most a
quarter more points.

Streaming benchmark (Xeon w7-3445, one core, 1 GiB of cell records,
-O3 -march=native -ffast-math), time per cell, gcc-16 / clang-22:

  hyperelastic tangent P2   4.02 / 6.78 us -> 2.41 / 2.30 us
  hyperelastic tangent P3  20.9 / 20.2 us  -> 11.3 / 12.0 us
  hyperelastic residual P2  324 / 256 ns   ->  166 / 191 ns
  hyperelastic residual P3  985 / 941 ns   ->  492 / 529 ns

Simplex Poisson and linear elasticity kernels are unchanged. Across 173
forms on affine and non-affine cells, L1-resident timings improve by a
geometric mean of 9% (gcc) and 5% (clang) on affine cells, and 22%
and 16% on non-affine cells.

Add benchmarks/streaming/roofline.py, which times kernels against a
memory-traffic-only kernel giving the per-core DRAM bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment each local element tensor block with the entries of A it
accumulates, and the entries its values or transpose also give when a
block is reused by symmetry. Note padded entries that never reach A,
padded quadrature points that are evaluated but not contracted, and the
dimension order of point-major tables.

Contraction comments no longer state that A is updated inside the
quadrature loop, and localised contraction sections list the local
blocks as their outputs. Generated code is otherwise unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pre-integrated contractions of blocked (vector) arguments stored to A at a
stride equal to the block size. Each test dof now evaluates its component
block rows contiguously into a small local array, interleaves them in a row
buffer and adds the row to A contiguously. When the summed tables prove the
blocks symmetric, only the upper blocks are computed, from flattened table
copies. Point-varying local blocks reuse the same row buffer scatter.

Value keys now expand polynomials separately for piecewise and varying
values, so equal blocks are recognised when their factors differ only in
ordering. Forms with fewer than three blocks keep the strided update, which
is cheaper there.

Streaming linear elasticity on tetrahedra improves from 57-69% to 68-81% of
the per-core DRAM bound, and hyperelastic P2/P3 kernels are about twice as
fast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSVC has no C99 _Complex, so mark the complex128 cases as expected
failures there, as elsewhere in the test suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-time every variant of the description tables that report the current
revision, using the stored sources and original method, so the tables
reflect the latest contraction changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A diagonal component block of a symmetric blocked tangent equals its own
transpose, which the block equivalence proof now detects. Its local array
is accumulated only from the SIMD column group holding the diagonal
onwards, with rows taken in groups of one vector width so every loop keeps
constant bounds and all blocks still share their table loads. The omitted
entries are mirrored once after the quadrature loop.

The split runs after loop-invariant hoisting and only for blocks with at
least four row groups; smaller blocks do not recover the cost of the extra
loops. Hyperelastic tangents on P3 tetrahedra are about 10% faster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocks too small for row groups keep their original statement order, so
their kernels are unchanged by the symmetric split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
garth-wells and others added 6 commits October 18, 2026 18:57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A constant factor on a point facet folds with its unit weight to a
literal, which has no symbol to record as a section input. In the jump
of a vector argument on interior facets, a block can equal another block
that is later found to be the transpose of a third; it now refers to the
third block directly instead of to a block without a local array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@garth-wells
garth-wells marked this pull request as ready for review October 25, 2026 19:55
garth-wells and others added 23 commits October 25, 2026 20:04
Preserve optimized coefficient evaluation with the section-list API and retain invariant quadrature handling alongside sum-factorised contractions.
Share helpers for repeated code: literal substitution and loop
unrolling (optimizer.substitute, unroll_loop), element tensor entries,
SIMD width, column sign normalisation, point-independent tensor
factors, unrolled coefficient sections and loop trip-count work
estimates. Compute the blocked row layout and the loop-split work
estimates once, merge the zero-padding of tables and weights, and build
block entry comments from the block layout. Generated code is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the SECTION_DESCRIPTIONS lookup by section name with an
optional description on L.Section, set by the code that builds the
section and kept when sections are rebuilt or fused. Formatters print
it directly, so renaming a section no longer drops its comment.
Generated code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the sub-node walk to optimizer.children and build symbol
reference collection, scalar use counting and the integral generator's
tree queries on it, instead of separate per-node-type walkers. Loop
bounds now count as reads, and uses inside array indices and
conditionals are counted. Generated code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FFCXBackendAccess.table_access returns the value of a table constant
over entities and points when it is read at a literal dof. The
coefficient and coordinate evaluations and the unrolled block
contraction then go through their general paths instead of reading
table values themselves. Literal dof indices of the element tensor are
folded, and unit factors now fold out of products. Machine code is
unchanged under gcc and clang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Register table symbols first, generate the quadrature loops, then
declare each table once with the zero padding and point-major copies
the loops recorded. This replaces patching declared table values in
place and searching the code for where to insert the copies. Weights
padding shares the table padding map. Generated code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry evaluation

The sum-factorised evaluation of a linear combination of dofs tracks
its stage shape, so directions can be contracted in any order.
Point-independent directions, such as the derivative direction of a
Q1 coordinate basis, go first: their differences of dofs are formed
before interpolating the others. This replaces a fully unrolled special
case limited to rules of at most three points, and also applies to
higher-degree forms on Q1 geometry, halving their Jacobian stage
arrays. Hot-loop timings of quadrilateral and hexahedron kernels at
degrees 1 to 3 are unchanged or up to 14% faster with gcc and clang
once code alignment is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the tensor computation description in a module constant beside
the integrand factors one. Describe a rank-two block that is not a
row and column layout by its flat entries, naming every argument
index. Update docstrings for the point-independent-first contraction
order and the stack budget estimate, which is an upper bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Share the evaluation order of a linear combination of dofs between the
stack budget estimate and code generation: point-independent directions
stored at one point and contracted first, then the others last
direction first, with stages keyed by direction and table. The estimate
previously counted every direction at every point, last direction
first, so it overstated the evaluation arrays and left the contraction
less of the stack limit than it could use. Fewer batches are now needed
under the same limit; lower the limit of one batching test so it still
splits into batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mypy infers the dictionary keys from UFL's metaclass, which a type
looked up from a terminal instance does not match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GCC and Clang completely unroll the short constant loops of the
directional contractions before loop vectorisation, which left the
stage updates of, for example, a four-point rule as scalar code. Loops
whose length fills whole SIMD vectors and whose contracted direction has
at least a vector's worth of points are now emitted as one plain loop
marked to stay rolled (FFCX_KEEP_ROLLED, a GCC or Clang unroll pragma
defined in the file preamble and empty for other compilers).

Integrand caches and stage arrays also store the contiguous last
direction padded to whole vectors when that costs at most a quarter
more storage, e.g. seven points to eight; padding entries are zero and
never reach A. Loops that do not fill whole vectors keep their previous
form, so Q1, Q2 and Q4 kernels in the benchmark are unchanged.

On distorted hexahedra with GLL Q3 elements, in-cache times improve by
1.3-2.2x (gcc) and 1.3-1.4x (clang): Laplace 8.7 to 4.9 us and
elasticity 160 to 74 us with gcc. Quadrilateral Q3 kernels improve by
1.1-1.6x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without -ffast-math, a call such as log(J) keeps the whole evaluation
loop scalar: errno must be preserved, and glibc only declares its SIMD
variants for GCC under __FAST_MATH__. Each library call now gets loops
of its own before the evaluation loop: the first computes its arguments
at every point, storing the values it computes, and the second applies
the function. The evaluation loop loads those values and the call
results, so it vectorises under any flags; only the short call loop may
stay scalar. The stored values share the fw cache stack budget.

Hyperelastic residuals on tetrahedra built without -ffast-math are
2.2-2.5x faster (P3: 1261 to 499 ns with gcc) and now within 10-20% of a
-ffast-math build, which also improves slightly. Loads with sin, exp and
log improve by 1.2-1.6x on geometric mean without -ffast-math; with it
they are 3-8% faster on average, with individual changes between -11%
and +23%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clang can vectorise a loop across its inner loops, unrolling them. For
a quadrature loop around contractions of 20 dofs this turns contiguous
table reads and element tensor updates into gathers and scatters, and
for test-function loops that update A at a stride along both loop
indices, as for the velocity blocks of a Taylor-Hood form, it scatters
across rows. Such loops are now marked FFCX_NO_VECTORIZE, a Clang pragma
defined in the file preamble and empty for other compilers, so the
inner loops vectorise instead.

Quadrature loops are marked only when an inner loop has at least three
SIMD vectors of iterations; across shorter inner loops Clang's choice
is better. Loops meant to vectorise across points (split evaluation and
library call loops) and sum-factorised kernels, where the effect was
mixed, are left alone.

With Clang and -ffast-math, a P3 tetrahedral load vector drops from 133
to 66 ns, level with GCC, and the Taylor-Hood Stokes form from 454 to
307 ns. Across 346 benchmark forms the geometric mean improves by 6-7%,
with no form more than 10% slower. GCC output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two changes to sum-factorised kernels on quadrilaterals and hexahedra.

Rows of the final-direction contraction that fill whole SIMD vectors
now accumulate in a loop kept rolled when the rule has more points in
that direction than a vector holds. Written out per entry over such a
quadrature loop, the compiler kept the accumulators in memory as
scalars, and the vector update of A then could not forward their
stores; this took over 40% of the time of a GLL Q3 weighted Laplacian
on hexahedra.

When integrand factors are stored unpadded and the rule has more points
than a vector, they are evaluated in one loop over all points rather
than a nest over the directions, whose innermost loop of two to five
points was too short to vectorise the geometry. The nest is kept if
anything in the loop reads a single direction's point index.

On distorted hexahedra, the weighted Laplacian is 1.6x faster at Q3
(28.3 to 16.3 us with gcc) and 1.2x at Q1 (494 to 394 ns); other Q1
and Q2 kernels gain up to 1.4x. A restructured contraction over all
dof pairs of the last two directions was also tried and rejected: it
loses the combination of terms sharing outer tables and was up to 2x
slower at Q3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lagrange and DG elements on quadrilaterals and hexahedra created with
basix.ufl.element number their dofs by entity rather than in tensor
product order, so only elements from create_tp_element were sum
factorised. Factorise these elements too, using the 1D factors of the
tensor-product-ordered element of the same family and variants, and the
element dof of each product of 1D basis functions, found by matching
basis functions at points in the cell.

Sum-factorised evaluation copies the dofs of such a coefficient or of
the coordinates into tensor product order before the first stage, and
the element tensor is updated through a table of the element dofs.
Ordinary quadrilateral and hexahedral meshes now sum-factorise their
geometry. The flat point loop test uses a quadratic mesh, whose
derivatives vary in every direction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With few dofs, contracting one direction at a time saves little work and
its loops cost more per operation, so low-order kernels such as a degree
1 Poisson operator on quadrilaterals were slower when sum factorised.
Estimate the multiply-adds contracting the argument tables each way, and
sum factorise a bilinear form only if it cuts them by at least
sum_factorization_min_speedup (default 1.5). Linear forms are always sum
factorised, as evaluating their coefficients one direction at a time
pays even at low degree.

Document that elements numbering their dofs by entity are sum factorised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The evaluation loop of the test's small contraction is only split from
the contraction on x86-64 Linux, where the compiler can call vector math
functions, so the test failed on macOS. Set the host as the neighbouring
platform test does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The option said whether to sum factorise, but its default now also
weighs the cost. Make it a string: "auto", the default, uses sum
factorisation where it is supported and, for a bilinear form, estimated
to pay; "always" uses it wherever it is supported, whatever the
estimated cost; and "never" never. Other values raise an error.

Lower the default sum_factorization_min_speedup from 1.5 to 1.33. With
the shortest exact rules, Laplace and elasticity forms of degree 2 on
quadrilaterals are estimated at 1.38, and Clang's ordinary quadrature
kernels for them are 4-5x slower. Degree 1 Poisson forms, estimated at
1.29, still use ordinary quadrature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The derivative of a degree 1 basis is the same at every point in its own
direction. A stage contracting over that direction summed the integrand
against the weighted product of the argument tables once for each dof
of every argument. Leave the constant tables out: sum against the
weights and the varying tables once, for each dof of the arguments whose
tables vary, before that direction's dof loops, and scale by the
constant tables where the stage is read.

A weighted Laplacian on degree 1 hexahedra with a three-point rule does
15% fewer floating point operations and takes 350 ns rather than 411 ns,
and a Laplacian on degree 1 hexahedra 448 ns rather than 526 ns (GCC).
The cost estimate choosing between sum factorisation and ordinary
quadrature is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jhale

jhale commented Nov 4, 2026

Copy link
Copy Markdown
Member

This PR is very hard to beat (unlike the current main)! Consequently it would be good to go ahead with polishing and merging it before the upcoming release.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants