Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,16 +198,20 @@ worktree changes, the worktree edits will not be active. Always:
2. `./uw build`
3. Run your code / tests from there

### AI-Assisted Commit Attribution
When committing code developed with AI assistance, end the commit message with:
### AI-Assisted Attribution (Commits and PRs)
When committing code or creating pull requests with AI assistance, end the
message/body with:

```
Underworld development team with AI support from Claude Code
Underworld development team with AI support from [Claude Code](https://claude.com/claude-code)
```

(In commit messages, use the plain-text form without the markdown link.)

**Do NOT use**:
- `Co-Authored-By:` with a noreply email (useless for soliciting responses)
- Generic AI attribution without team context
- Emoji in PR descriptions

---

Expand Down
26 changes: 26 additions & 0 deletions petsc-custom/build-petsc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ clone_petsc() {
echo "Clone complete."
}

apply_patches() {
echo "Applying UW3 patches to PETSc..."
cd "$PETSC_DIR"

# Fix ghost facet double-counting in boundary residual/integral assembly.
# Without this, internal boundary natural BCs and BdIntegral produce
# incorrect results in parallel (shared facets integrated on multiple ranks).
# Submitted upstream: [TODO: add PETSc MR link when available]
local patch="${SCRIPT_DIR}/patches/plexfem-ghost-facet-fix.patch"
if [ -f "$patch" ]; then
if git apply --check "$patch" 2>/dev/null; then
git apply "$patch"
echo " Applied: plexfem-ghost-facet-fix.patch"
else
echo " Skipped: plexfem-ghost-facet-fix.patch (already applied or conflict)"
fi
fi

echo "Patches complete."
}

configure_petsc() {
echo "Configuring PETSc with AMR tools..."
cd "$PETSC_DIR"
Expand Down Expand Up @@ -147,6 +168,7 @@ show_help() {
echo " build Build PETSc"
echo " test Run PETSc tests"
echo " petsc4py Build and install petsc4py"
echo " patch Apply UW3 patches to PETSc source"
echo " clean Remove PETSc directory"
echo " help Show this help"
}
Expand All @@ -155,6 +177,7 @@ show_help() {
case "${1:-all}" in
all)
clone_petsc
apply_patches
configure_petsc
build_petsc
build_petsc4py
Expand All @@ -175,6 +198,9 @@ case "${1:-all}" in
build)
build_petsc
;;
patch)
apply_patches
;;
test)
test_petsc
;;
Expand Down
79 changes: 79 additions & 0 deletions petsc-custom/patches/PETSC_MR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# PETSc Merge Request: Ghost Facet Fix

## Submission details

- **Fork**: https://gitlab.com/lmoresi/petsc
- **Branch**: `fix/plexfem-ghost-facet-boundary-residual`
- **Target**: `petsc/petsc` → `release`
- **Create MR**: https://gitlab.com/lmoresi/petsc/-/merge_requests/new?merge_request%5Bsource_branch%5D=fix%2Fplexfem-ghost-facet-boundary-residual

## Title

DMPlex: filter ghost facets from boundary residual and integral assembly

## Description

### Problem

`DMPlexComputeBdResidual_Internal` and `DMPlexComputeBdIntegral` construct
`facetIS` as all facets at depth `dim-1` but do not exclude ghost facets
(point SF leaves). The volume residual code in `DMPlexComputeResidualByKey`
checks the `"ghost"` label before calling `DMPlexVecSetClosure` (line 5355),
but the boundary residual path has no equivalent check.

For **external** boundaries this is benign: ghost facets on the domain
boundary still have `support[0]` pointing to a valid local cell, and
`DMLocalToGlobal(ADD_VALUES)` correctly resolves ownership. But for
**internal** boundaries — facets in the mesh interior that carry a
`DMLabel` value and a `DM_BC_NATURAL` condition — facets at partition
junctions are present on multiple ranks. Each rank independently
integrates the boundary flux via `PetscFEIntegrateBdResidual`, and
`DMLocalToGlobal(ADD_VALUES)` sums all contributions, causing
double-counting.

The same issue affects `DMPlexComputeBdIntegral`: ghost boundary facets
are included in the per-face integral sum, which is then `MPI_Allreduce`d,
again double-counting shared facets.

### Use case: internal boundaries in geodynamics

[Underworld3](https://github.com/underworldcode/underworld3) is a
Python/PETSc geodynamics framework that uses DMPlex FEM throughout.
Geophysical models commonly require traction (natural) boundary conditions
on **internal surfaces** — for example, a fault plane or a material
interface embedded within the mesh. These are set up by labeling interior
facets with a `DMLabel` and registering `DM_BC_NATURAL` via
`PetscDSAddBoundary` / `PetscWeakFormAddBdResidual`.

This works correctly in serial but produces O(1) errors whenever the
labeled internal boundary is split across partition boundaries, because
shared facets are integrated on multiple ranks.

### Fix

After obtaining `facetIS` from `DMLabelGetStratumIS(depthLabel, dim-1, ...)`,
filter out SF leaves using `ISDifference`. This ensures each boundary facet
is processed by exactly one rank (the owner).

This follows the same pattern already used in `DMPlexComputeBdFaceGeomFVM`
(plexfem.c, around line 1087) which calls `PetscFindInt(face, nleaves, leaves, &loc)`
to skip ghost faces during FVM face flux computation.

### Testing

Tested with Underworld3 Stokes solver using `DM_BC_NATURAL` on an internal
boundary (annular mesh with labeled interior circle). Results match serial
to 10+ significant figures across all processor counts tested (1, 2, 4, 8).
Before the fix, any partition that splits the internal boundary produces
O(1) errors from double-counted boundary contributions.

The patch applies cleanly to all PETSc releases from v3.18.0 through
v3.24.5 — the affected code has been structurally unchanged across these
versions.

## Reference examples

- Stokes Green's function with internal boundary natural BCs:
https://github.com/underworldcode/underworld3/blob/development/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Kernels.py
- Cartesian variant:
https://github.com/underworldcode/underworld3/blob/development/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_Kernels.py
59 changes: 59 additions & 0 deletions petsc-custom/patches/plexfem-ghost-facet-fix.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
diff --git a/src/dm/impls/plex/plexfem.c b/src/dm/impls/plex/plexfem.c
index 0299f569070..26e6432585d 100644
--- a/src/dm/impls/plex/plexfem.c
+++ b/src/dm/impls/plex/plexfem.c
@@ -2874,6 +2874,26 @@ PetscErrorCode DMPlexComputeBdIntegral(DM dm, Vec X, DMLabel label, PetscInt num
PetscCall(DMPlexGetDepthLabel(dm, &depthLabel));
PetscCall(DMGetDimension(dm, &dim));
PetscCall(DMLabelGetStratumIS(depthLabel, dim - 1, &facetIS));
+ /* Filter out ghost facets (SF leaves) so that each boundary facet is only
+ counted on one rank. Without this, shared facets at partition boundaries
+ are integrated on multiple ranks, causing double-counting after MPI sum. */
+ if (facetIS) {
+ PetscSF sf;
+ PetscInt nleaves;
+ const PetscInt *leaves;
+
+ PetscCall(DMGetPointSF(dm, &sf));
+ PetscCall(PetscSFGetGraph(sf, NULL, &nleaves, &leaves, NULL));
+ if (nleaves > 0 && leaves) {
+ IS leafIS, ownedFacetIS;
+
+ PetscCall(ISCreateGeneral(PETSC_COMM_SELF, nleaves, leaves, PETSC_USE_POINTER, &leafIS));
+ PetscCall(ISDifference(facetIS, leafIS, &ownedFacetIS));
+ PetscCall(ISDestroy(&leafIS));
+ PetscCall(ISDestroy(&facetIS));
+ facetIS = ownedFacetIS;
+ }
+ }
PetscCall(DMGetLocalSection(dm, &section));
PetscCall(PetscSectionGetNumFields(section, &Nf));
/* Get local solution with boundary values */
@@ -5113,6 +5133,27 @@ static PetscErrorCode DMPlexComputeBdResidual_Internal(DM dm, Vec locX, Vec locX
PetscCall(DMPlexGetDepthLabel(dm, &depthLabel));
PetscCall(DMGetDimension(dm, &dim));
PetscCall(DMLabelGetStratumIS(depthLabel, dim - 1, &facetIS));
+ /* Filter out ghost facets (SF leaves) so that boundary residual contributions
+ from shared facets are only assembled on the owning rank. Without this,
+ internal boundary natural BCs at partition junctions get double-counted
+ because LocalToGlobal with ADD_VALUES sums contributions from all ranks. */
+ if (facetIS) {
+ PetscSF sf;
+ PetscInt nleaves;
+ const PetscInt *leaves;
+
+ PetscCall(DMGetPointSF(dm, &sf));
+ PetscCall(PetscSFGetGraph(sf, NULL, &nleaves, &leaves, NULL));
+ if (nleaves > 0 && leaves) {
+ IS leafIS, ownedFacetIS;
+
+ PetscCall(ISCreateGeneral(PETSC_COMM_SELF, nleaves, leaves, PETSC_USE_POINTER, &leafIS));
+ PetscCall(ISDifference(facetIS, leafIS, &ownedFacetIS));
+ PetscCall(ISDestroy(&leafIS));
+ PetscCall(ISDestroy(&facetIS));
+ facetIS = ownedFacetIS;
+ }
+ }
PetscCall(PetscDSGetNumBoundary(prob, &numBd));
for (bd = 0; bd < numBd; ++bd) {
PetscWeakForm wf;
103 changes: 103 additions & 0 deletions src/underworld3/cython/petsc_compat.h
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,106 @@ PetscErrorCode UW_DMPlexSetSNESLocalFEM(DM dm, PetscBool flag, void *ctx)
return DMPlexSetSNESLocalFEM(dm, flag, NULL);
#endif
}

// Simplified wrapper for DMPlexComputeBdIntegral.
// Takes a single boundary pointwise function (for field 0) instead of an Nf-element array.
//
// Fixes two issues in DMPlexComputeBdIntegral:
// 1. Missing MPI Allreduce (plexfem.c returns local contribution only,
// unlike DMPlexComputeIntegralFEM which reduces at plexfem.c:2633).
// 2. Ghost facet double-counting: DMPlexComputeBdIntegral iterates over ALL
// label stratum points including ghost facets, so shared boundary facets
// get integrated on both the owning rank and the ghost rank. We create a
// temporary label containing only owned (non-ghost) boundary points.
PetscErrorCode UW_DMPlexComputeBdIntegral(DM dm, Vec X,
DMLabel label, PetscInt numVals, const PetscInt vals[],
void (*func)(UW_SIG_F0),
PetscScalar *result,
void *ctx)
{
PetscSection section;
PetscInt Nf, pStart, pEnd;

PetscFunctionBeginUser;

PetscCall(DMGetLocalSection(dm, &section));
PetscCall(PetscSectionGetNumFields(section, &Nf));

// If label is NULL (boundary not present on this rank), contribute 0
// but still participate in the MPI Allreduce to avoid hangs.
if (!label) {
PetscScalar zero = 0.0;
PetscCallMPI(MPIU_Allreduce(&zero, result, 1, MPIU_SCALAR, MPIU_SUM,
PetscObjectComm((PetscObject)dm)));
PetscFunctionReturn(PETSC_SUCCESS);
}

// --- Build a ghost-point bitset from the point SF ---
PetscSF sf;
PetscInt nleaves;
const PetscInt *ilocal;
PetscCall(DMGetPointSF(dm, &sf));
PetscCall(PetscSFGetGraph(sf, NULL, &nleaves, &ilocal, NULL));
PetscCall(DMPlexGetChart(dm, &pStart, &pEnd));

PetscBT ghostBT;
PetscCall(PetscBTCreate(pEnd - pStart, &ghostBT));
if (ilocal) {
for (PetscInt i = 0; i < nleaves; i++) {
PetscCall(PetscBTSet(ghostBT, ilocal[i] - pStart));
}
}

// --- Create a temporary label with only owned boundary points ---
DMLabel ownedLabel;
PetscCall(DMLabelCreate(PetscObjectComm((PetscObject)dm), "uw_owned_bd", &ownedLabel));

for (PetscInt v = 0; v < numVals; v++) {
IS origIS;
PetscCall(DMLabelGetStratumIS(label, vals[v], &origIS));
if (!origIS) continue;

PetscInt n;
const PetscInt *indices;
PetscCall(ISGetLocalSize(origIS, &n));
PetscCall(ISGetIndices(origIS, &indices));

for (PetscInt i = 0; i < n; i++) {
if (!PetscBTLookup(ghostBT, indices[i] - pStart)) {
PetscCall(DMLabelSetValue(ownedLabel, indices[i], vals[v]));
}
}
PetscCall(ISRestoreIndices(origIS, &indices));
PetscCall(ISDestroy(&origIS));
}
PetscCall(PetscBTDestroy(&ghostBT));

// --- Compute boundary integral over owned points only ---
void (**funcs)(UW_SIG_F0);
PetscCall(PetscCalloc1(Nf, &funcs));
funcs[0] = func;

PetscScalar *integral;
PetscCall(PetscCalloc1(Nf, &integral));

// PETSc changed DMPlexComputeBdIntegral signature in v3.22.0:
// <= 3.21.x: void (*func)(...) — single function pointer
// >= 3.22.0: void (**funcs)(...) — array of Nf function pointers
#if PETSC_VERSION_GE(3, 22, 0)
PetscCall(DMPlexComputeBdIntegral(dm, X, ownedLabel, numVals, vals, funcs, integral, ctx));
#else
PetscCall(DMPlexComputeBdIntegral(dm, X, ownedLabel, numVals, vals, funcs[0], integral, ctx));
#endif

// --- MPI reduction (sum local owned contributions across all ranks) ---
PetscScalar global_val;
PetscCallMPI(MPIU_Allreduce(&integral[0], &global_val, 1, MPIU_SCALAR, MPIU_SUM,
PetscObjectComm((PetscObject)dm)));
*result = global_val;

PetscCall(DMLabelDestroy(&ownedLabel));
PetscCall(PetscFree(funcs));
PetscCall(PetscFree(integral));

PetscFunctionReturn(PETSC_SUCCESS);
}
3 changes: 2 additions & 1 deletion src/underworld3/cython/petsc_extras.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ cdef extern from "petsc_compat.h":
PetscErrorCode UW_PetscDSSetBdJacobianPreconditioner(PetscDS, PetscDMLabel, PetscInt, PetscInt, PetscInt, PetscInt, PetscInt, void*, void*, void*, void*)
PetscErrorCode UW_PetscDSSetBdTerms (PetscDS, PetscDMLabel, PetscInt, PetscInt, PetscInt, PetscInt, PetscInt, void*, void*, void*, void*, void*, void* )
PetscErrorCode UW_PetscDSViewWF(PetscDS)
PetscErrorCode UW_PetscDSViewBdWF(PetscDS, PetscInt)
PetscErrorCode UW_PetscDSViewBdWF(PetscDS, PetscInt)
PetscErrorCode UW_DMPlexSetSNESLocalFEM( PetscDM, PetscBool, void *)
PetscErrorCode UW_DMPlexComputeBdIntegral( PetscDM, PetscVec, PetscDMLabel, PetscInt, const PetscInt*, void*, PetscScalar*, void*)

cdef extern from "petsc.h" nogil:
PetscErrorCode DMPlexSNESComputeBoundaryFEM( PetscDM, void *, void *)
Expand Down
Loading