BdIntegral: sandbox DM to prevent shared DM_Plex cache corruption - #125
Conversation
DMPlexComputeBdIntegral lazily initialises height-trace FE objects on the coordinate DMField, which is cached on the shared DM_Plex struct. Since DMClone shares DM_Plex by refcount, this corrupts all solver DMs cloned from the same mesh — the next Jacobian assembly fails with MPI errors (ranks disagree on off-process matrix entries). Fix: BdIntegral.evaluate() now creates a disposable sandbox DM via DMClone + fresh createCoordinateSpace. The sandbox has its own coordinate DMField with empty height-trace caches, so the lazy init during the integral writes to the sandbox's caches, not the original mesh's. The sandbox is destroyed after each call. Reproducer: mpirun -np 2 with cellSize=0.05 on UnstructuredSimplexBox, solve → BdIntegral → solve. Fails without this fix on PETSc 3.25.0 (and intermittently on 3.24.x depending on mesh partitioning). Related: #96 Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
There was a problem hiding this comment.
Pull request overview
This PR addresses a PETSc parallel correctness issue where calling DMPlexComputeBdIntegral can mutate shared DM_Plex-associated caches and corrupt other solver DMs cloned from the same mesh, causing subsequent Jacobian assembly/MPI failures. It introduces a disposable “sandbox” DM for BdIntegral.evaluate() to isolate those mutations.
Changes:
- Update
BdIntegral.evaluate()to run the boundary integral on a disposable sandbox DM and destroy it afterward. - Add a new PETSc C helper
UW_DMCreateBdIntegralSandbox()to clone a DM and rebuild its coordinate field/caches for safe BdIntegral evaluation. - Expose the new helper to Cython via
petsc_extras.pxi.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
src/underworld3/cython/petsc_maths.pyx |
Uses a sandbox DM for BdIntegral evaluation and destroys it after the call. |
src/underworld3/cython/petsc_extras.pxi |
Adds Cython extern declaration for UW_DMCreateBdIntegralSandbox. |
src/underworld3/cython/petsc_compat.h |
Implements UW_DMCreateBdIntegralSandbox() to rebuild coordinate space/field and copy field setup/aux vec. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| cdef PetscDMLabel c_dmlabel = NULL | ||
| cdef DMLabel dmlabel |
There was a problem hiding this comment.
These Cython locals (c_dmlabel, dmlabel) are now unused after switching to sandbox_label. Please remove them to avoid confusion and keep the evaluate() logic minimal.
| cdef PetscDMLabel c_dmlabel = NULL | |
| cdef DMLabel dmlabel |
| // Rebuild coordinate space from scratch — creates a new coordinate | ||
| // DMField_DS with empty height-trace caches, independent of src's. | ||
| PetscCall(DMPlexCreateCoordinateSpace(sdm, 1, PETSC_FALSE, PETSC_TRUE)); | ||
| PetscCall(UW_DMForceCoordinateField(sdm)); |
There was a problem hiding this comment.
UW_DMCreateBdIntegralSandbox() hard-codes DMPlexCreateCoordinateSpace(sdm, 1, ...). Underworld meshes can be created with degree>1 (quadratic/triquadratic), and using degree=1 for the sandbox coordinate space can change the geometry mapping and yield incorrect boundary integrals on curved/high-order meshes. Please derive the coordinate degree from src (or pass it in from Python, e.g. mesh.degree) and use that instead of 1.
| PetscErrorCode UW_DMCreateBdIntegralSandbox(DM src, const char labelName[], | ||
| DM *sandbox) | ||
| { |
There was a problem hiding this comment.
The labelName parameter is currently unused in UW_DMCreateBdIntegralSandbox(), but is threaded through from Cython. If it’s not needed, remove it from the signature to avoid confusion (or explicitly mark it unused to silence compiler warnings).
| // Copy fields + DS from src so the section layout matches | ||
| PetscCall(DMGetLocalSection(src, &srcSec)); | ||
| PetscCall(PetscSectionGetNumFields(srcSec, &Nf)); | ||
| for (i = 0; i < Nf; ++i) { | ||
| PetscObject obj; | ||
| DMLabel label; | ||
| PetscCall(DMGetField(src, i, &label, &obj)); | ||
| PetscCall(DMSetField(sdm, i, label, obj)); | ||
| } | ||
| PetscCall(DMCreateDS(sdm)); | ||
| PetscCall(DMSetLocalSection(sdm, srcSec)); |
There was a problem hiding this comment.
This comment says “Copy fields + DS from src…”, but the implementation copies fields and then calls DMCreateDS(sdm) (which recreates a DS from options rather than copying src’s DS). Consider correcting the comment to reflect what the code actually does (copy fields/section, then recreate DS) to avoid future maintenance mistakes.
| # Create a sandbox DM that is topologically identical to mesh.dm | ||
| # but has an independent DM_Plex struct. DMPlexComputeBdIntegral | ||
| # mutates shared DM_Plex caches (height-trace FEs, closure indices) | ||
| # which corrupts solver DMs that were cloned from the same mesh. | ||
| # Running the integral on a disposable sandbox avoids this. |
There was a problem hiding this comment.
The comment says the sandbox DM has an “independent DM_Plex struct”, but UW_DMCreateBdIntegralSandbox() currently starts with DMClone(), which (as noted in the C comment) shares the underlying DM_Plex by refcount. This is misleading; consider rewording to state that the sandbox rebuilds the coordinate field/coordinate DMField caches (not the DM_Plex) to avoid cache contamination.
| # Create a sandbox DM that is topologically identical to mesh.dm | |
| # but has an independent DM_Plex struct. DMPlexComputeBdIntegral | |
| # mutates shared DM_Plex caches (height-trace FEs, closure indices) | |
| # which corrupts solver DMs that were cloned from the same mesh. | |
| # Running the integral on a disposable sandbox avoids this. | |
| # Create a disposable sandbox DM that is topologically identical to | |
| # mesh.dm. The sandbox is used to rebuild coordinate field / | |
| # coordinate DMField-related caches needed by boundary integration | |
| # without contaminating cached state on the solver DM. This does | |
| # not imply an independent underlying DM_Plex struct. |
| cdef PetscDM sandbox_dm = NULL | ||
| cdef bytes boundary_bytes = self.boundary.encode('utf-8') | ||
| CHKERRQ(UW_DMCreateBdIntegralSandbox( | ||
| (<DM>mesh.dm).dm, boundary_bytes, &sandbox_dm)) | ||
|
|
||
| # Get the label from the sandbox DM | ||
| cdef PetscDMLabel c_dmlabel = NULL | ||
| cdef DMLabel dmlabel | ||
| boundary_enum = mesh.boundaries[self.boundary] | ||
| cdef PetscInt label_val = boundary_enum.value | ||
| cdef PetscInt num_vals = 1 | ||
|
|
||
| c_label = mesh.dm.getLabel(self.boundary) | ||
| cdef PetscDMLabel sandbox_label = NULL | ||
| CHKERRQ(DMGetLabel(sandbox_dm, boundary_bytes, &sandbox_label)) | ||
|
|
||
| # Output value | ||
| cdef PetscScalar result = 0.0 | ||
|
|
||
| # Label can be None if this boundary has no facets on this process | ||
| # (normal in parallel) or if the DM doesn't carry the label at all. | ||
| # The C wrapper handles NULL labels gracefully (contributes 0 to the | ||
| # MPI reduction so all ranks still participate). | ||
| cdef PetscDMLabel c_dmlabel = NULL | ||
| cdef DMLabel dmlabel | ||
| if c_label: | ||
| dmlabel = c_label | ||
| c_dmlabel = dmlabel.dmlabel | ||
|
|
||
| # Call the boundary integral | ||
| # Call the boundary integral on the SANDBOX DM | ||
| ierr = UW_DMPlexComputeBdIntegral( | ||
| dm_c.dm, cgvec.vec, | ||
| c_dmlabel, num_vals, &label_val, | ||
| sandbox_dm, cgvec.vec, | ||
| sandbox_label, num_vals, &label_val, | ||
| ext.fns_bd_residual[0], | ||
| &result, NULL | ||
| ) | ||
| CHKERRQ(ierr) | ||
|
|
||
| # Clean up sandbox | ||
| CHKERRQ(DMDestroy(&sandbox_dm)) | ||
| mesh.dm.restoreGlobalVec(a_global) | ||
|
|
There was a problem hiding this comment.
sandbox_dm and the borrowed global vector (a_global) are only cleaned up on the success path. If UW_DMCreateBdIntegralSandbox(), DMGetLabel(), or UW_DMPlexComputeBdIntegral() raises via CHKERRQ, DMDestroy(&sandbox_dm) and mesh.dm.restoreGlobalVec(a_global) will be skipped, leaking PETSc objects and potentially leaving the DM’s global Vec checked-out. Please wrap the sandbox/global-vec lifetime in a try/finally (or equivalent) to guarantee cleanup on errors.
PETSc 3.24.x: DMPlexCreateCoordinateSpace(dm, degree, project, snapFunc) PETSc 3.25.0: DMPlexCreateCoordinateSpace(dm, degree, localized, project) Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
- Remove unused labelName parameter from UW_DMCreateBdIntegralSandbox - Remove unused c_dmlabel/dmlabel Cython locals - Fix comment: sandbox shares DM_Plex topology via DMClone, the independent part is the coordinate DMField (not the whole DM_Plex) - Clarify DS comment (DMCreateDS recreates from fields, not a copy) Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
Summary
DMPlexComputeBdIntegrallazily initialises height-trace FE caches on the coordinateDMField, which lives on the sharedDM_Plexstruct. SinceDMClonesharesDM_Plexby refcount, this corrupts all solver DMs cloned from the same mesh -- the next Jacobian assembly fails with MPI errors (ranks disagree on off-process matrix entries).BdIntegral.evaluate()now creates a disposable sandbox DM (DMClone+ freshcreateCoordinateSpace) with its own coordinate field caches. The sandbox is destroyed after each call.DMClone_Plexsharing mutable caches). Not optimised -- sandbox is created/destroyed per call, which is correct for mesh deformation and adaptation scenarios.Reproducer
Fails with
mpirun -np 2on PETSc 3.25.0 (and intermittently on 3.24.x depending on mesh partitioning). Mesh size dependent -- only triggers when the partition creates cross-process boundary faces.Test plan
mpirun -np 2with cellSize 0.03--0.1 (all pass, previously 0.05/0.09 failed)exp(T)) with mesh variable in integrandRelated
Underworld development team with AI support from Claude Code