Block-accelerated contact Hessian assembly via MeshFEMSparse - #246
Open
zfergus wants to merge 11 commits into
Open
Block-accelerated contact Hessian assembly via MeshFEMSparse#246zfergus wants to merge 11 commits into
zfergus wants to merge 11 commits into
Conversation
Adds a reusable contact-scene fixture (8 scenes spanning 390 to 512k collisions, each padded with interior vertices so to_full_dof performs a genuine surface-to-volume scatter) and Catch2 benchmarks that isolate the three costs of contact Hessian/gradient assembly: 1. per-collision (local) derivative evaluation, 2. global assembly (triplets + setFromTriplets), 3. the reduced-DOF map (CollisionMesh::to_full_dof). Baseline findings: local derivative evaluation is only 1.8-7.6% of Hessian cost; the rest is assembly bookkeeping (42-62%) and to_full_dof SpGEMMs (30-56%). On the largest scene (puffer-ball, 512k collisions) bookkeeping costs ~560 ms per Newton iteration vs 21 ms of derivative math. Also adds a memory-guarded scene probe ([assembly-probe], hidden) that counts broad-phase candidates before building the collision set, since an oversized dhat can exhaust host memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds an in_full_dof parameter to Potential<T>::gradient/hessian. When the mesh's DOF map is a pure selection matrix (the default; tracked by the new CollisionMesh::is_selection_dof_map()), stencil vertex IDs are remapped to full-mesh IDs during triplet generation, producing the full-DOF result directly instead of applying to_full_dof afterwards. This eliminates the two serial SpGEMMs (S^T H S), which were 30-56% of end-to-end Hessian cost. With a user-provided displacement map, in_full_dof falls back to to_full_dof internally, so the flag is always safe. Measured end-to-end Hessian speedups: 1.29-1.83x across 8 scenes (390-512k collisions). Gradient folding is not beneficial on large scenes (the thread-local accumulators grow to full_ndof while the SpMV saved is cheap) and is left off by default; documented in the benchmark. Note: the defensive storage-empty path in hessian() now returns a correctly-sized (ndof x ndof) empty matrix instead of 0x0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e 2) Extracts the global-matrix construction out of Potential<T>::hessian into an abstract HessianAssembler interface (begin / thread-safe add_local_hessian / end). The historical triplet + setFromTriplets path moves verbatim into TripletHessianAssembler, and hessian() becomes a thin wrapper over the new public Potential<T>::assemble_hessian driver, which also owns the Phase 1 full-DOF stencil remap so every future backend gets it for free. No behavior change; benchmarks confirm collision-DOF assembly times are within run-to-run noise of the previous implementation on all 8 scenes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds MeshFEMHessianAssembler, a HessianAssembler backed by MeshFEMSparse's block-CSC data structures (Mohammadian et al., SIGGRAPH 2026): begin() builds a block sparsity pattern from the collision stencils and add_local_hessian() scatters each local Hessian directly into the value array via MeshFEM's sorted column-merge with per-column spin locks — no triplets, no setFromTriplets. Guarded by IPC_TOOLKIT_WITH_MESHFEM_SPARSE (default OFF). The HessianAssembler seam gains a StencilGetter argument to begin() so pattern-based backends can see stencils up front. The dependency is fetched with CPM DOWNLOAD_ONLY (pinned SHAs + SHA256 archive hashes) and compiled into a minimal static target (matrix data structures and assembly only, no sparse direct solvers), avoiding upstream's PUBLIC -fvisibility=hidden, its solver sources (which clash with Eigen 5's BLAS declarations), and its transitive dependency fetching. Compatibility notes: - MeshFEM targets Eigen 3.4; Eigen 5 removed internal::make_coherent, which MeshFEMCore/AutomaticDifferentiation.hh references (included by SparseMatrices.hh at the root of the header chain). A force-included shim (meshfem_eigen_compat.hpp) reimplements the Eigen 3.4 semantics. - BlockCSCHessian::toEigen/toScalar read out of bounds on empty block columns (impossible for FE Hessians, ubiquitous for contact Hessians: most vertices are collision-free), causing intermittent segfaults. Replaced with a custom direct block-CSC -> symmetric Eigen conversion, which is also ~2x faster than upstream's two-step expansion. Measured on 8 scenes (390-512k collisions), full-DOF Hessian, pattern rebuilt every call: 2.5-11x end-to-end vs the triplet path to an Eigen matrix, 3-15x to the block-CSC format. Matches the triplet assembler to <= 1e-13 relative across scenes x PSD projection x DOF space. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MeshFEMHessianAssembler is now designed to live across assemblies (e.g., one instance per Newton solve). begin() compares the stencils against the cached block pattern via MeshFEM's detectChangedEntries and reuses it (values-only reset + scatter) unless the contact set gained a new vertex pair or lost more than stale_block_tolerance() blocks; stale blocks assemble to explicit zeros. The Eigen conversion structure (symmetrized pattern + index arrays) is cached the same way, so get_matrix() — now returning a const reference valid until the next begin() — reduces to a parallel value refill while the pattern holds. For callers that know the collision set is identical to the previous assembly (change detection costs a sizable fraction of a rebuild on large scenes), set_assume_unchanged_stencils(true) skips detection entirely; a differing stencil count falls back to detection automatically and debug builds verify the assumption. Amortization is automatic through the existing assemble_hessian seam — no API changes beyond the new accessors. Steady-state contact Hessians (Eigen output included) reach 3.3-29x over the triplet baseline across the 8 benchmark scenes (e.g., cloth-ball 14 ms -> 0.48 ms, puffer-ball ~600 ms -> 32 ms), with reuse semantics covered by new tests (identical/shrunken/grown sets, tolerance behavior, assume-unchanged fallback). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Potential<T>::gradient now selects between two assembly strategies based on problem shape (no API change, no new dependency): - gather (new): local gradients are written to a flat per-slot buffer, a vertex->slot adjacency is built with a parallel counting sort, and each vertex sums its contributions independently. Cost scales with the number of contributions rather than ndof. - scatter+reduce (previous behavior): thread-local dense accumulators whose zero+combine cost scales with ndof. Gather is selected when out_ndof > 4 * num_collisions, the empirical crossover on the benchmark scenes: contact-sparse large meshes get gather (cloth-ball 512-612 -> 381 us, n-body 917 -> 695 us), while collision-dense scenes (rod-twist: 1.3M contributions on 120k DOF, where gather's buffer + adjacency traffic measured 1.6x worse) keep the scatter path. This also removes the Phase 1 caveat that in_full_dof gradients could be slower: with gather the accumulators no longer grow with full_ndof (cloth-ball 595 -> 444 us, puffer-ball 13.7 -> 11.3 ms folded). Summation order remains floating-point nondeterministic on both paths; sorting each gather bucket would make that path reproducible if ever needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IPC_TOOLKIT_WITH_MESHFEM_SPARSE now defaults to ON (auto-disabled for IPC_TOOLKIT_VERTEX_DERIVATIVE_LAYOUT=ColMajor, which the block layout does not support), and Potential<T>::hessian() assembles through the block-CSC backend when compiled in, via a new zero-copy MeshFEMHessianAssembler::take_matrix(). The triplet path remains as the fallback when the option is off. Every existing hessian() caller gets the speedup with no code change: cloth-ball 5-6.7 -> 1.5 ms, armadillo-rollers 11-18 -> 2.2 ms, rod-twist 165-212 -> 29.5 ms, puffer-ball 375-1020 -> 48.9 ms (identical results up to floating-point summation order; full 286-test suite passes in both configurations). The dependency is now pinned to fork commits carrying the two fixes submitted upstream (MeshFEM/MeshFEMCore#1 for Eigen 5 support, MeshFEM/MeshFEMSparse#1 for an out-of-bounds read on empty block columns) -- marked TEMPORARY in the recipe; repoint to upstream SHAs once merged. This allowed deleting the force-included make_coherent compatibility shim entirely. Also: document the HessianAssembler classes in the C++ API docs (with IPC_TOOLKIT_WITH_MESHFEM_SPARSE added to Doxygen's PREDEFINED so the guarded class renders) and add MeshFEMSparse to the optional-dependency docs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Single-capital-letter names for matrices (H = Hessian, M = matrix) are the codebase's mathematical convention; NOLINT the readability-identifier-naming check on them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #246 +/- ##
==========================================
+ Coverage 96.58% 96.65% +0.07%
==========================================
Files 163 168 +5
Lines 16673 16910 +237
Branches 922 954 +32
==========================================
+ Hits 16103 16344 +241
+ Misses 570 566 -4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Adds MeshFEMHessianAssembler::block_matrix(), which returns the assembled matrix in MeshFEMSparse's native block-CSC form so a downstream user can feed it to MeshFEM's block SpMV or Cholesky factorizers instead of paying for the Eigen conversion (0.11 vs 0.30 ms on bunny, 42.8 vs 51.9 ms on puffer-ball). MeshFEM::BlockCSCHessianBase is forward declared, so our header still does not pull in MeshFEMSparse's; callers that want the block matrix include <MeshFEMSparse/BlockCSCHessian.hh> themselves and everyone else pays nothing. Binds assemble_hessian, HessianAssembler, TripletHessianAssembler, and MeshFEMHessianAssembler to Python, so Python callers can now hold an assembler across iterations and get pattern reuse (previously they were limited to the cold path inside hessian()). All three classes are py::is_final(): a Python-defined assembler would take the GIL once per collision, which is hundreds of thousands of times per assembly on the larger scenes. Exercising block_matrix() turned up a third instance of the empty-block- column assumption upstream, in visitDiagonalScalarEntries, which made trace() read the preceding column's storage (1951.93 against a dense trace of 447.82) and addDiag()/setDiag() write to the wrong entries. Fixed in the pinned fork commit alongside the other two (MeshFEM/MeshFEMSparse#1); the tests now cover trace() agreement and that addDiag() rejects a pattern with missing diagonal blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tutorial still showed to_full_dof as the only way to get full-mesh derivatives, and said nothing about holding an assembler across a Newton solve, which is where most of the speedup lives. Adds an in_full_dof example next to the existing to_full_dof one (with a note on the pure-selection requirement and the silent fallback when a displacement map is present), and a section on reusing a MeshFEMHessianAssembler: what the cached pattern covers, when it is rebuilt, block_matrix() for solvers that speak block CSC, and the assume_unchanged_stencils escape hatch and its caveat. Also drops the now-wrong "two fixes" count for the pinned forks; the MeshFEMSparse PR carries two empty-block-column fixes of its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Contact Hessian assembly spends almost none of its time on math. Across the eight benchmark scenes here, evaluating the local per-collision Hessians accounts for 2-8% of
Potential::hessian(); everything else is building triplets, sorting them insidesetFromTriplets, and then multiplying by the selection matrix twice into_full_dof.This PR replaces both halves of that. Local Hessians scatter straight into MeshFEMSparse's block-CSC storage (Mohammadian et al., MeshFEM: A Block-accelerated Solver for Nonlinear Finite Elements, SIGGRAPH 2026), and the full-mesh DOF map is folded into that scatter instead of applied afterwards. Callers do not have to change anything to benefit: on puffer-ball (512k collisions) a Hessian went from ~600 ms to 49 ms. Values match the old path to floating point rounding.
What's in this PR
One commit per step, so this can be reviewed or reverted piecewise.
to_full_dof. Every number below comes from these.Potential::gradient/hessiantake a newin_full_dofflag. When the mesh's DOF map is a plain selection matrix, which is the case unless someone passed a custom displacement map, stencil vertex IDs get remapped to full-mesh IDs during assembly and the twoto_full_dofproducts disappear. Non-selection maps fall back to the old behavior internally, so the flag is always safe to pass.HessianAssemblerinterface.Potential::assemble_hessian(collisions, mesh, X, assembler, ...)separates local derivative evaluation from global matrix construction. The old triplet code moves intoTripletHessianAssemblerunchanged.MeshFEMHessianAssembler, the block-CSC backend. It builds a block sparsity pattern from the collision stencils, then scatters local Hessians into the value array with MeshFEM's sorted column-merge and per-column locks, which skips triplet construction and thesetFromTripletssort completely.sparsityPatternUpdateThreshold, plus an opt-in fast path for callers who know the set is unchanged.IPC_TOOLKIT_WITH_MESHFEM_SPARSEdefaults to ON andPotential::hessian()routes through the block backend, with the triplet path kept as the fallback.MeshFEMHessianAssembler::block_matrix()hands out the matrix in its native block-CSC form, and the assemblers are bound to Python.Dependency notes
MeshFEMSparse and MeshFEMCore are fetched with CPM
DOWNLOAD_ONLY(pinned SHAs, plus SHA256 hashes on the archives) and compiled into a small static target of five source files. That gets us the matrix data structures and the assembly routines without the sparse direct solver wrappers, so SuiteSparse never enters the picture. Transitive dependencies are Eigen and TBB, both of which we already build. MIT licensed.Integrating it turned up three upstream bugs, which I fixed and submitted rather than worked around locally. MeshFEM/MeshFEMCore#1 is Eigen 5 support: Eigen 5 removed
internal::make_coherent, which MeshFEMCore's autodiff header calls. MeshFEM/MeshFEMSparse#1 covers the other two, both of which come from the same assumption that every block column has a diagonal block. That holds for FE Hessians, where every node belongs to an element, and fails for contact Hessians, where only vertices currently in contact appear at all. The recipe pins fork commits carrying all three patches, marked TEMPORARY; once those PRs land it is a two-line change to point back at upstream.Before approving this, note that it changes the default numerical output of
hessian()within floating point reordering. Any downstream test that compares Hessians for exact equality will notice.Performance report
Summary
End-to-end Hessian speedup against the current triplet baseline, measured within the same run (assembly plus DOF map, Eigen output).
to_full_dofThe interface column is deliberately a wash; it is a refactor and I checked that it costs nothing. Steps 3 and 4 subsume the folding from step 2, which stays useful for anyone building without MeshFEMSparse. "Assume unchanged" skips change detection when the caller asserts the collision set is identical.
Puffer-ball is thermally noisy across runs, so its baseline moves around; against the fastest baseline I ever measured for it those last two columns are 13.2x and 17.6x rather than 18.7x and 23.6x. Every other number is a same-run comparison.
Setup: Apple Silicon macOS (Darwin 25.5.0), AppleClang 21, Release, CUDA off. Harness is
tests/src/tests/potential/benchmark_assembly.cpp, run as./ipc_toolkit_tests "[assembly]" --benchmark-samples 10 --benchmark-no-analysis.Each scene mesh is padded with an equal number of unreferenced interior vertices, so
to_full_dofdoes a real surface-to-volume scatter rather than a permutation. That is how simulators actually embed collision meshes, and without it the DOF map looks artificially cheap.two-cubes-close.ply, dhat=1e-1)bunny.ply, dhat=1e-2)cloth_ball92.ply, dhat=1e-3)cloth-funnel/227.ply, dhat=4e-3)armadillo-rollers/326.ply, dhat=1e-3)n-body-simulation/balls16_18.ply, dhat=1.78e-2)rod-twist/3036.ply, dhat=1.09e-3)puffer-ball/20.ply, dhat=1.44e-4)The simulation-frame scenes use dhat = 1e-3 times the bbox diagonal. Be careful raising these: collision count grows superlinearly, and an order of magnitude more dhat on cloth-ball will exhaust memory on a 64 GB machine. There is an
[assembly-probe]test that counts broad-phase candidates first, which is how these values were chosen.Baseline (triplet assembly on
main)Hessian path, Catch2 means over 10 samples:
Ten assemblies of an unchanged collision set cost exactly ten times one assembly (two-cubes 3.78 ms, bunny 10.2 ms, cloth-ball 121.3 ms, armadillo-rollers 192.9 ms). Nothing is amortized across Newton iterations today, which is the entire opportunity behind step 5.
Cost breakdown, median of 5, all eight scenes. "Assembly" is the full call minus the local-only pass; "full-DOF" is the
to_full_dofproducts.Peak RSS for the full eight-scene run is 4.4 GB.
Gradients, for comparison:
So 92-98% of a contact Hessian is bookkeeping. On puffer-ball that is 560 ms of every Newton iteration. Pure assembly is the larger share at scale (61-62% on the two biggest scenes), which is what block-CSC assembly removes, while
to_full_dofis 30-56% everywhere and is what folding removes. The gradient path is in much better shape, though thetbb::combinablereduction still costs 2-11x the local work on big meshes: cloth-ball spends 612 µs to produce 54 µs worth of derivatives, almost all of it zeroing and combining thread-localVectorXd(ndof)at 139k DOF.Folding the DOF map into assembly
The folded Hessian time is essentially the collision-DOF assembly time on its own. Remapping stencil IDs costs nothing measurable and the two sparse products vanish.
(That puffer-ball baseline is the noisy one again; the ratios are within-run.)
Gradients were a different story, and this surprised me. Folding made the big scenes slower:
The thread-local accumulators double in size when you assemble in full DOF, and zeroing and combining them costs more than the
to_full_dofSpMV you saved, which was only microseconds to a couple of milliseconds. Step 6 fixes the underlying reduction and this inverts.On correctness:
test_full_dof_assembly.cppchecks folded against mapped to 1e-13 relative on scenes with genuine interior-vertex selection, with and without PSD projection, plus the non-selection fallback and empty-collision sizing. The comparison cannot be exact because thread partitioning makes the summation order vary between runs, typically at 1e-16.The assembler interface
begin(ndof, dim, num_stencils, stencil), a thread-safeadd_local_hessian(local_hess, vertex_ids),end(). The old triplet code moved intoTripletHessianAssemblerverbatim, including its low-memory serial fallback, andhessian()became a thin wrapper overassemble_hessian. Folding lives in the driver, so every backend gets it without doing anything.Collision-DOF Hessian totals before and after, to confirm the virtual call per collision does not register:
All within run-to-run noise. Puffer-ball is again the outlier for thermal reasons rather than algorithmic ones.
The MeshFEMSparse backend
begin()builds the block sparsity pattern from the stencils viaSystemAssembler::blockSparsityPattern;add_local_hessian()scatters each local Hessian into the value array using MeshFEM's sorted column-merge with per-column spin locks.I did not take it entirely as-is. I compile MeshFEMSparse myself from a
DOWNLOAD_ONLYfetch rather than through its CMake, which sidesteps its solver wrappers, itsPUBLIC -fvisibility=hidden, and its own fetching of Eigen, TBB and MeshFEMCore. I also wrote the block-CSC to Eigen conversion instead of callingtoEigen: it symmetrizes directly in parallel rather than going through an intermediate upper-triangle scalar matrix, which is about 2x faster even before caching, and it can reuse a cached structure across assemblies, whichtoEigencannot.The Eigen version mismatch was the real integration cost. MeshFEM targets Eigen 3.4 and we pin 5.0.1, and Eigen 5 removed
internal::make_coherent. Because the call is qualified, name lookup happens at template definition time, so merely including MeshFEMCore's autodiff header fails to compile, andSparseMatrices.hhincludes it at the root of everything. That is now fixed upstream in MeshFEM/MeshFEMCore#1 and the local shim is gone. I also applyEIGEN_DONT_VECTORIZE=1to the MeshFEM target when SIMD is on, since compiling the same Eigen templates two ways is an ODR violation with real alignment consequences.The other two upstream bugs took longer to find, and they are the same mistake in two places.
CSCMatrix::expandSparsityPatterndetects diagonal blocks by readingAi[Ap[j+1] - 1], which is out of bounds for an empty block column; that showed up as intermittent SIGSEGVs on the large scenes until AddressSanitizer pinned it in one run.visitDiagonalScalarEntriesdoes the analogous thing withdiagBlockScalarLoc(), which for an empty column points into the preceding column's storage, sotrace()returned 1951.93 where the dense trace is 447.82 andaddDiag()wrote to the wrong entries. I only noticed the second one becauseapply()on the same matrix matched Eigen exactly, which ruled out the values and left the offsets. Both are fixed in MeshFEM/MeshFEMSparse#1.Worth knowing if you use
block_matrix(): the guard that should have caught this,missingRequiredDiagonalBlocks(), excludes the storage layout we use, soassertSupportsAssembly()never fires. I left that alone upstream rather than widen it, because assembly itself is genuinely fine (the assembler only touches columns a stencil references, and those always have their diagonal block) and widening it would makesetZero()throw for legitimately sparse patterns.Results with the pattern rebuilt on every call, median of 5, in full DOF. The first speedup column is what an Eigen-consuming caller sees; the second is what a caller that can take the block format directly would see.
Because the conversion is cheap, Eigen consumers already get most of the win without any API change. Puffer-ball drops from 571 ms to 52 ms, and 21 ms of what remains is the local derivative evaluation itself. The tests compare against the triplet assembler at 1e-13 relative on every scene, in both PSD projection modes and both DOF spaces. Three consecutive full benchmark runs have been clean since the empty-column fix, where before I could not get through one reliably.
Reusing the pattern
begin()runs MeshFEM'sdetectChangedEntriesagainst the cached pattern. It reuses the pattern unless the contact set gained a vertex pair, or lost more blocks thanstale_block_tolerance()allows, which defaults to zero. Stale blocks assemble to explicit zeros, so the values stay correct either way.The Eigen conversion structure gets cached the same way, so
get_matrix()becomes a parallel value refill. The interface does not change at all. Holding one assembler acrossassemble_hessiancalls is enough to get all of this.Steady-state Hessians now cost 0.1 to 43 ms where the triplet path cost 0.4 to 800 ms. On cloth-ball the steady state (0.49 ms) is about twice the local derivative cost (0.23 ms), so assembly has more or less stopped being a line item. Tests cover the reuse decisions: identical set reuses, a shrunken set reuses only within tolerance and still produces exact values, and any new vertex pair forces a rebuild regardless of tolerance, because scattering into a missing entry would be undefined behavior.
Notice rod-twist gets nothing from reuse. At that size
detectChangedEntriescosts about as much as rebuilding, which is what motivatedset_assume_unchanged_stencils(true): a caller who knows the set is identical, say because it is reassembling with a different PSD projection or stiffness, can skip detection. A differing stencil count disproves the assumption and falls back automatically, debug builds verify the claim, and a wrong assertion with matching counts is documented as undefined behavior.The reused numbers for the two biggest scenes are better in this run than the previous one, so some of the detection cost is just noise. The fast path removes it either way.
Gradients
Two strategies with opposite scaling, picked by problem shape. The gather path writes local gradients to a flat buffer, builds a vertex-to-slot adjacency with a parallel counting sort, and lets each vertex sum its own contributions, so cost tracks the number of contributions. The scatter path is the existing thread-local accumulators, whose cost tracks
ndof. Gather wins whenout_ndofexceeds the number of contributions, which is the measured crossover.I tried gather everywhere first. It regressed rod-twist by 1.6x, which has 1.3M contributions over 120k DOF, so the buffer and adjacency traffic outweighs what thread-local accumulators cost there. Hence the split rather than a wholesale replacement.
This also removes the earlier caveat about folding gradients: cloth-ball goes from 595 µs to 444 µs folded, puffer-ball from 13.7 ms to 11.3 ms. Summation order is still not fixed on either path, so results vary by rounding between runs. Sorting each gather bucket would make that path reproducible if we ever want it.
Gradients were never the headline. These are 1.2-1.6x in the favorable regime and parity elsewhere, on a path that costs microseconds to milliseconds against the Hessian's milliseconds to hundreds of milliseconds. The remaining
tbb::combinablesites (friction force, Gauss-Newton diagonal, smooth contact) could adopt the same helper later.Getting at the block matrix, and Python
get_matrix()converts toEigen::SparseMatrix, which is what most callers want. If you are already using MeshFEM, that conversion is wasted work, soblock_matrix()returns the assembledBlockCSCHessianBasedirectly and you can hand it to MeshFEM's block SpMV or its Cholesky factorizers. On bunny that is 0.11 ms instead of 0.30 ms; on puffer-ball 42.8 instead of 51.9.MeshFEM::BlockCSCHessianBaseis forward declared in our header, so includingmeshfem_hessian_assembler.hppstill does not drag in any MeshFEM header. Callers who want the block matrix include<MeshFEMSparse/BlockCSCHessian.hh>themselves.One caveat, since the pattern only covers vertices in contact: reading operations are fine (
trace()skips the missing diagonals,apply()and the sparsity pattern are unaffected), butaddDiag()andsetDiag()have no entry to write and throw. Insert the missing diagonal blocks first if you need them.assemble_hessianand all three assembler classes are bound to Python, so a Python caller can hold an assembler across iterations and get pattern reuse instead of being stuck on the cold path insidehessian():The classes are
py::is_final(). Subclassing them in Python would only be useful with a trampoline, and a Python-defined assembler would take the GIL once per collision, which is half a million times per assembly on puffer-ball.Making it the default
IPC_TOOLKIT_WITH_MESHFEM_SPARSEnow defaults to ON, andhessian()assembles through the block backend when it is compiled in, using atake_matrix()that moves the matrix out instead of copying it. With the option off you get the triplet path. AColMajorderivative layout disables the option automatically with a warning, since the block layout needs per-vertex blocks.That shifts the bottleneck.
to_full_dofis now 75-84% of end to end for anyone not passingin_full_dof=true, so that flag is the next thing worth wiring up in PolyFEM. The assume-unchanged path also beats cold assembly now on the two biggest scenes, 21.6 and 30.1 ms against 31.9 and 49.3 ms.The full ctest suite (286 tests) passes with the option on and with it off. CI's existing matrix picks up the new default on all three platforms, which is the first time the backend gets built with MSVC, so that job is worth watching. The off path has no dedicated CI job.
Docs: the three assembler classes are in the C++ API docs,
IPC_TOOLKIT_WITH_MESHFEM_SPARSEis in Doxygen'sPREDEFINEDso the guarded class renders, and MeshFEMSparse is in the optional-dependencies table. The Python bindings coverin_full_dof,is_selection_dof_map,assemble_hessian, and the assembler classes.MeshFEMHessianAssembleris inside the option's#ifdef, so a wheel built with the option off would not export it; if that matters we should define the name unconditionally and raise on construction instead.🤖 Generated with Claude Code