Skip to content

Assembly performance optimisations - #4438

Merged
garth-wells merged 30 commits into
mainfrom
garth/perf-opt
Aug 31, 2026
Merged

Assembly performance optimisations#4438
garth-wells merged 30 commits into
mainfrom
garth/perf-opt

Conversation

@garth-wells

@garth-wells garth-wells commented Aug 22, 2026

Copy link
Copy Markdown
Member

Summary

Performance optimisations for per-cell/facet/entity matrix and vector
assembly, targeting std::function indirection for DOF transformations and
geometry/coefficient-fetch overhead. No public API or behavioural changes.

DOF transformations: skip the closure entirely when none is needed

  • FiniteElement::dof_transformation_fn/dof_transformation_right_fn now
    return nullptr instead of a no-op closure when an element needs no DOF
    transformation, rather than allocating and invoking an empty
    std::function on every cell.
  • All assembly, interpolation and packing call sites guard on this before
    invoking, via a new fem::is_transform_set() helper (traits.h): it
    checks truthiness for a nullable std::function, and always returns
    true for a non-nullable callable such as the plain lambda the
    custom_kernel demo passes when calling impl::assemble_* directly. The
    guard is hoisted out of the per-cell/per-facet loop wherever the
    transform is loop-invariant.
  • An earlier version of this PR also added a directly-callable
    (non-type-erased) DirectDofTransform path for elements that genuinely
    need a transformation applied at assembly time (H(curl)/H(div) families
    such as N1curl/RT — scalar Lagrange elements of any degree never do,
    since their permutations are resolved once at dofmap-construction time,
    not per cell; see needs_dof_transformations()). Benchmarking against
    N1curl/RT mass-matrix assembly (isolating just this path, holding every
    other change constant) showed it only wins for vector assembly of
    degree-1 elements (~4-7%) and is flat-to-negative for matrix assembly at
    any degree and for vector assembly at degree 2 — not worth ~200 extra
    lines in FiniteElement.h and 16 call-site conversions, so it was
    dropped. See the Benchmarks section below.

Geometry/coefficient fetch across cell, facet, and entity integrals

assemble_cells/assemble_cells_matrix, assemble_entities, and
assemble_interior_facets (in both assemble_matrix_impl.h and
assemble_vector_impl.h) fetch each cell's coordinate DOFs via raw-pointer
indexing into the geometry dofmap and coordinate array (avoiding
md::submdspan construction overhead in the innermost loop), then copy the
coordinates with std::copy_n. The same loop-invariant transform check
(is_transform_set, hoisted out of the loop) and std::copy_n gather-loop
cleanup is applied to fem::impl::pack_impl/pack_coefficient_entity in
pack.h. An earlier version of the geometry copy used a manual
per-component copy loop instead of std::copy_n, which regressed vector
assembly of P1 Lagrange forms by ~7.6%: perf-annotated, unstripped
production binaries showed it compiles to three separate 8-byte scalar
loads/stores per vertex, vs. std::copy_n's vectorised 16+8-byte copy for
the same 24 bytes — GCC 13 doesn't give a hand-written copy loop the same
treatment as std::copy_n's specialised small-copy path. Fixed by
restoring std::copy_n for the copy itself while keeping the raw-pointer
index computation.

Compile-time block size for matrix assembly

impl::assemble_matrix's cell-integral, interior-facet, and
exterior-facet/entity dispatch now branches on the test/trial block size
(bs0/bs1) at compile time for the common bs == 1 and bs == 3 cases
(via std::integral_constant<int, 1/3>, threaded through new
DofMapPackCells/DofMapPackEntities/DofMapPackFacets-constrained
dofmap parameters), falling back to the runtime int block size
otherwise — mirroring the dispatch already used in impl::assemble_vector.

Other fixes found along the way

  • A mismatched DOF-transformation guard in assemble_matrix_impl.h's
    interior-facet code (checked one transform but applied the other).
  • Two GCC/portability failures not visible to clang locally: an
    if constexpr on entities.rank() inside a lambda that captures
    entities by reference (rejected by GCC as not a constant expression;
    fixed via remove_cvref_t<decltype(entities)>::rank()), and the
    sign-compare/template-deduction fallout from the
    std::integral_constant-based block-size dispatch above (fixed to use
    std::integral_constant<int, N> and explicit std::tuple{...} wrapping
    at the custom_kernel demo's direct call sites).

Benchmarks

Serial (1 rank), Release build, unit-cube tetrahedral mesh, median of 15
repetitions, this branch vs. current main. Correctness cross-checked on
every run: matrix nnz/Frobenius norm identical between main and this
branch in every case below.

Poisson (-div(grad(u)) = f), scalar Lagrange:

case main branch Δ
P1 matrix reassemble (N=50, 750k cells) 112.1 ms 105.1 ms +6.2%
P1 vector assemble 23.4 ms 23.4 ms +0.1%
P3 matrix reassemble (N=20, 48k cells) 403.6 ms 332.6 ms +17.6%
P3 vector assemble 10.0 ms 10.0 ms +0.1%

H(curl)/H(div) mass matrix (inner(u, v)*dx) — the element families
that actually exercise the DOF-transformation-guard change, since Lagrange
elements never need a transformation at assembly time:

case main branch Δ
N1curl deg1 matrix reassemble (N=24, 102k dofs) 41.2 ms 39.6 ms +3.9%
N1curl deg1 vector assemble 12.2 ms 11.9 ms +1.9%
N1curl deg2 matrix reassemble (N=16, 163k dofs) 145.0 ms 135.5 ms +6.5%
N1curl deg2 vector assemble 5.9 ms 6.1 ms −2.0%
RT deg1 matrix reassemble (N=24, 169k dofs) 29.8 ms 29.1 ms +2.4%
RT deg1 vector assemble 12.8 ms 12.6 ms +1.4%

Vector-assembly deltas above are all within typical run-to-run noise for
this benchmark harness (±1-2%) except where noted; the matrix-assembly wins
(driven by the geometry-fetch fix and, for P1/P3, the new bs dispatch)
are the consistent, reproducible result of this PR. The interior-facet and
exterior-facet/entity paths (DG/discontinuous forms, Neumann/Robin
boundary integrals) receive the same geometry-fetch and bs-dispatch
treatment but were not separately re-benchmarked; the numbers above are
representative of the cell-integral case, which dominates assembly cost
for the forms exercised here.

Testing

  • Full local C++ test suite (Catch2, cpp/test): 27476 assertions pass (70
    test cases pass, 3 skipped as parallel-only when run serially).
  • Local Python fem (2384 passed) and io (198 passed; ADIOS2-dependent
    tests skip in this environment, unrelated to this change) test suites
    pass.
  • custom_kernel demo (which calls impl::assemble_* directly with a
    non-nullable lambda kernel, exercising the is_transform_set fallback
    path) builds and runs correctly, with Assembler0 (std::function-based)
    and Assembler1 (direct-lambda) assembly producing matching norms.

garth-wells and others added 15 commits August 22, 2026 08:58
dof_transformation_fn/dof_transformation_right_fn now return nullptr
(instead of a no-op closure) when an element needs no DOF
transformation, avoiding an indirect call in the hot per-cell
assembly loop. This finishes guarding every remaining call site
(interpolate.h, discreteoperators.h, FunctionSpace.h, Function.h,
pack.h, vtk_utils.h, and the interior-facet paths in
assemble_matrix_impl.h/assemble_vector_impl.h) that was still
invoking the closure unconditionally and crashing with
std::bad_function_call.

Also fixes a mismatched guard in assemble_matrix_impl.h's
interior-facet code (checked P1T but called P0), and guards the
mixed-element sub_element_fns loop in FiniteElement.h, where
individual heterogeneous sub-elements can return nullptr even when
the composite element needs a transformation overall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Introduces FiniteElement::with_dof_transformation_fn and
with_dof_transformation_right_fn, which invoke a continuation with
either a directly-callable, inlinable DirectDofTransform{,Right}
(for non-mixed, non-blocked elements) or the existing type-erased
std::function closure, and updates all assembly/interpolation/packing
call sites to use them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s paths

FunctionSpace::tabulate_dof_coordinates and
tabulate_lagrange_dof_coordinates are only reachable for
interpolation_ident() (point-evaluation, e.g. Lagrange) elements,
which never need_dof_transformations(); the DirectDofTransform branch
in with_dof_transformation_fn is therefore dead code at these two call
sites. Benchmarking showed the wrapping cost ~20-30% extra wall time
in these hot per-cell loops for no benefit, reproducible across
repeated before/after process launches. Revert just these two call
sites to the plain dof_transformation_fn() form; the assembly/
interpolation call sites where the direct path is actually reachable
are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…CI)

Two real bugs, both invisible to clang locally:

1. assemble_expression_impl.h: `if constexpr (entities.rank() == 2)`
   inside a nested lambda that captures `entities` by reference is
   rejected by GCC ("'entities' is not a constant expression") since
   forming the constant expression now goes through a runtime
   reference. Use `std::remove_cvref_t<decltype(entities)>::rank()`,
   which depends only on the type.

2. assemble_matrix_impl.h/assemble_vector_impl.h: `if (P0)`/`if (P1T)`
   assumed the transform kernel is always contextually convertible to
   bool (true for std::function and DirectDofTransform), but the
   custom_kernel demo calls these impl:: functions directly with a
   plain, non-nullable lambda, which has no operator bool. Add
   fem::is_transform_set(), which checks truthiness only when the
   callable supports it and otherwise always returns true, and use it
   at all four guard sites.

Verified: reproduced both failures locally against the real headers
with Homebrew g++-16 (matching the AlmaLinux/ci-build/docs CI
failures) and icpx-style rejection of the demo (matching the oneapi
job), confirmed both are fixed, and confirmed the custom_kernel demo
still runs correctly. Full local C++ (27532 assertions) and Python
fem+io (2624 tests) suites pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "Update geometry fetch" commit added compile-time block-size
dispatch to assemble_cells_matrix using
std::integral_constant<std::size_t, N>, but the function's internal
BC-zeroing loops use `for (int k = 0; k < bs0; ++k)`. Comparing an int
against an unsigned integral_constant value tripped
-Werror=sign-compare on GCC (matching assemble_vector_impl.h's
existing convention of std::integral_constant<int, N>, which doesn't
have this clash). Switch to std::integral_constant<int, N>.

Also, assemble_cells_matrix's dofmap0/dofmap1 parameters became a
constrained `auto` (DofMapPackCells) instead of a concrete
std::tuple, so the custom_kernel demo's direct calls passing bare
braced-init-lists ({dofmap.map(), 1, cells}) no longer deduce --
template arguments cannot be deduced from a braced-init-list. Wrap
them in std::tuple{...} explicitly.

Verified: cpp/test/matrix.cpp and the custom_kernel demo, which
reproduced both CI failures, now compile and the demo runs correctly.
Full local C++ suite (27532 assertions) passes.
@garth-wells
garth-wells marked this pull request as ready for review August 28, 2026 09:52
garth-wells and others added 5 commits August 28, 2026 11:49
The raw-pointer geometry-fetch loop introduced for the matrix/vector
assembly speedups compiled to three separate 8-byte scalar loads/stores
per vertex instead of std::copy_n's vectorised 16+8-byte copy, causing
a ~7.6% regression in vector assembly of P1 Lagrange forms (confirmed
via perf-annotated production binaries, main vs branch). Restored
std::copy_n for the copy itself while keeping the raw-pointer index
computation.

Benchmarking N1curl/RT forms (the only elements where
is_direct_transform_eligible() can be true) showed DirectDofTransform
gives a real win only for vector assembly of degree-1 elements and is
flat-to-negative everywhere else (matrix assembly at any degree,
vector assembly at degree 2). Not worth the ~200 lines in
FiniteElement.h and 16 call-site conversions, so it is removed
entirely; all call sites revert to the plain dof_transformation_fn()
closure, keeping the independently-proven wins: nullptr-return +
is_transform_set guards, and the compile-time bs dispatch in
assemble_matrix_impl.h.

Reverting interpolate.h/discreteoperators.h to their pre-PR structure
initially reintroduced a bad_function_call crash: those functions
assumed the transform closure is never null (true on main, since
dof_transformation_fn() used to return a no-op closure instead of
nullptr), an assumption this branch's nullptr-return change breaks.
Added if (transform) guards at every previously-unconditional call
site to match the convention already used correctly elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remote's 'Updates'/'Simplifications' commits touched the
DirectDofTransform machinery (explicit lambda captures, pack_impl
template simplification) that this branch has since removed entirely
(see c70b518). Kept local's version on every conflicting hunk.
The merge (ba1f6c6) auto-combined remote's pack_impl signature
simplification (template<int _bs,...> -> template<...>(..., auto bs,
...), no more if constexpr branching) with this branch's unmodified
pack_coefficient_entity call site, since the two hunks didn't
textually overlap. The result didn't compile: pack_impl<bs_c()>(...)
no longer matches a signature with no non-type template parameter.
Updated the call site to pass bs_c/bs as a regular argument instead of
an explicit template argument, matching the new signature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
garth-wells and others added 6 commits August 28, 2026 16:14
insert_csr, insert_blocked_csr, insert_nonblocked_csr, and MatrixCSR's
ghost-row unpacking each duplicated the same std::lower_bound-based
column search. Factored into a shared la::impl::csr_position() helper.

A hand-rolled "branchless" (conditional-move) rewrite of this search
was tried first, on the theory that the column found is unpredictable
from one call to the next so a branchy std::lower_bound mispredicts.
It measured as a reproducible ~13% regression in
scripts/bench_backend.py's csr_reasm (P1, N=100): GCC 13 did not turn
the idiom into a cmov, confirmed by disassembly -- it kept a real
conditional branch, structurally near-identical to libstdc++'s own
lower_bound, just without libstdc++'s tuning. Reverted to a thin
wrapper around std::lower_bound instead; the deduplication alone
measured a genuine, reproducible +6.2% on P1/N=100 csr_reasm (no
significant change on P3/N=64, where kernel cost dominates insertion
cost). The failed branchless attempt is documented in a code comment
so it isn't re-tried without new evidence.

Drive-by: MatrixCSR::add() passed _row_ptr.size() as its debug-only
row-range bound, one row looser than set()'s correct
size_local()+num_ghosts(); aligned add() to match, closing a
theoretical one-row out-of-bounds read for row == num_all_rows() in
Developer builds.

Testing: cpp/test full suite (27476 assertions) and python/test
unit/la + unit/fem (2594 tests) pass unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port the compile-time block-size dispatch and raw-pointer geometry
fetch already used for cell integrals to the interior-facet and
entity (exterior-facet/vertex/ridge) integral paths in
assemble_matrix_impl.h and assemble_vector_impl.h, closing a gap left
by the earlier cell-only optimisation. The matrix-side facet/entity
kernels now take DofMapPackEntities/DofMapPackFacets-constrained
dofmap arguments instead of fixed tuple types, enabling the same
bs0==1/bs1==1, bs0==3/bs1==3 dispatch already used for cells.

Also apply the same loop-invariant-transform-check and std::copy_n
gather-loop cleanups to fem::impl::pack_impl/pack_coefficient_entity
in pack.h.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
clang if-converts csr_position's std::lower_bound into a branchless
cmov chain, ~35-50% slower here than GCC's branchy codegen (the cmov
serializes each iteration's load behind the prior comparison, killing
speculation on this short, cache-resident row). Confirmed via a
standalone reproducer and disassembly; a hand-rolled loop with
__builtin_expect recovers full speed, but reimplementing lower_bound
over a compiler codegen quirk isn't worth it, so left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	cpp/dolfinx/fem/assemble_matrix_impl.h
#	cpp/dolfinx/fem/assemble_vector_impl.h
//-----------------------------------------------------------------------------
template <std::floating_point T>
std::string FiniteElement<T>::signature() const noexcept
const std::string& FiniteElement<T>::signature() const noexcept

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High potential for dangling reference.

Comment thread cpp/dolfinx/la/matrix_csr_impl.h Outdated
/// `>= col`. Callers must check `cols[pos] == col` themselves to
/// detect a genuinely missing entry; this function does not throw.
///
/// A single shared search used by `insert_csr`, `insert_blocked_csr`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Results, copy into PR text as comment.

garth-wells and others added 3 commits August 31, 2026 08:07
Reverts the shared-lower_bound refactor and the add()/set() row-bound
fix to their pre-PR #4438 state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@garth-wells
garth-wells enabled auto-merge August 31, 2026 08:41
@garth-wells
garth-wells added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 37076bf Aug 31, 2026
22 checks passed
@garth-wells
garth-wells deleted the garth/perf-opt branch August 31, 2026 09:48
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