Fix out-of-bounds read in expandSparsityPattern for empty block columns - #1
Fix out-of-bounds read in expandSparsityPattern for empty block columns#1zfergus wants to merge 2 commits into
Conversation
The diagonal-block detection in CSCMatrix::expandSparsityPattern reads Ai[Ap[block_j + 1] - 1], which for an empty block column reads the last entry of the previous column -- or Ai[-1] when the empty column precedes any nonzeros. When the stray value happens to equal block_j, the column size computation (numBlocks - 1) * N + 1 underflows with numBlocks == 0, corrupting the InOrderBuilder's column sizes and causing out-of-bounds writes (intermittent SIGSEGVs in BlockCSCHessian::toScalar/toEigen). FE Hessians always have diagonal blocks (every node belongs to an element), but patterns built from contact stencils are mostly empty columns -- only vertices currently in contact appear -- which is how this was found (AddressSanitizer repro: a SystemAssembler<3> blockSparsityPattern over a single 2-vertex stencil among 1000 block variables, followed by toScalar()). Guard the detection on numBlocks > 0; the filler loop below is already safe for empty columns.
visitDiagonalScalarEntries walked every block column and took diagBlockScalarLoc() for each. For an empty block column that offset is N^2 * Ap[bj] - N^2, which points into the preceding column's storage, or below the start of Ax entirely when the first block column is empty. trace() therefore summed unrelated values (and read out of bounds) on any matrix with empty block columns, and addDiag()/setDiag() wrote into the wrong entries. This is the same assumption behind the out-of-bounds read in expandSparsityPattern fixed in the previous commit: FE Hessians always have a diagonal block per column because every node belongs to an element, but Hessians assembled from contact stencils leave most columns empty, since only vertices currently in contact appear. Skip empty columns while still advancing the scalar column index, so trace() ignores their (structurally zero) diagonals. The mutating operations cannot be fixed by skipping, because there is no stored entry to write, so they now check for the missing blocks and throw instead of corrupting neighboring columns. Note that missingRequiredDiagonalBlocks(), and hence assertSupportsAssembly(), does not catch this: it excludes the StoreFullDiagonalBlocks case, where diagBlockScalarLoc() is equally invalid for an empty column. I left that alone rather than widen it, since assembly itself is unaffected -- the assembler only touches columns a stencil references, and those always contain their diagonal block.
|
Pushed a second commit to this PR: the same assumption shows up again in for (_Index bj = 0; bj < n; ++bj) {
auto cs = columnScanner(bj);
_Index loc = cs.diagBlockScalarLoc(); // <-- for an empty column?
The visible symptom was The commit skips empty columns while still advancing the scalar column index, so One thing I deliberately did not change: Both fixes are validated through the IPC Toolkit's block-CSC assembly backend (ipc-sim/ipc-toolkit#246): 🤖 Addressed by Claude Code |
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>
CSCMatrix::expandSparsityPattern<UniformBlockSize>()detects the presence of a diagonal block withFor a block column with zero entries (
Ap[block_j] == Ap[block_j + 1]), this reads the last row index of the previous non-empty column — orAi[-1]when the empty column precedes any nonzeros. When the stray value happens to equalblock_j,hasDiagonalis spuriously true andunderflows to a huge
size_t, corrupting theInOrderBuilder's column-size array and causing out-of-bounds writes (we observed intermittent SIGSEGVs insideBlockCSCHessian::toScalar/toEigen).Why FEM never hits this: every node belongs to an element, so every block column has at least its diagonal block. We hit it using
SystemAssembler::blockSparsityPatternfor IPC contact Hessians, where the overwhelming majority of block columns are empty (only vertices currently in contact appear).AddressSanitizer repro (fails before this change, clean after):
Fix: guard the detection on
numBlocks > 0(the filler loop below already iterates the empty range safely).A possibly-related observation while reading
BlockCSCHessian::toScalar: theuniformBlockSize()fast path computes its result and then falls through without returning it —so the fast path's work is discarded (it was also how the OOB above got executed even though its result was unused). Possibly a missing
return result;— left out of this PR since "fixing" it changes which code path serves all uniform-block-size callers, and you're best placed to judge intent.🤖 Generated with Claude Code