Fix VE_Stokes first-solve parallel deadlock (#130) - #136
Conversation
mesh._get_coords_for_basis contains PETSc collectives (DMClone, createInterpolation, globalToLocal). It was populated lazily from MeshVariable.coords_nd, which is accessed from rank-local code paths such as rbf_interpolate inside global_evaluate_nd's per-particle loop. On partition geometries where some ranks had only interior migrated particles, those ranks skipped the RBF path while others entered it — leaving the collective waiting forever. Pre-populate the coord cache at the end of _BaseMeshVariable.__init__ (a collective context) so every variable's basis is filled by all ranks together. Also repopulate for all registered variables at the end of nuke_coords_and_rebuild so mesh adaptation and DM rebuilds don't re-open the same deadlock window. Adds tests/parallel/test_0780_ve_stokes_first_solve_mpi.py exercising the canonical np=4, 16x8 failure case. Verified against the full (np, mesh) matrix from the issue: previously hanging configs (np=3 with 16x8/32x16, np=4 with 16x8, np=16 with 16x8) all complete; previously OK configs still OK; level_1+tier_a suite passes. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
Fixes an MPI deadlock in the VE_Stokes first solve() by ensuring mesh coordinate-basis caches are populated collectively (avoiding rank-asymmetric entry into PETSc collectives), and adds a parallel regression test for the previously hanging configuration.
Changes:
- Eagerly pre-populates per-variable mesh coordinate caches during
MeshVariableconstruction to avoid lazy collective calls from rank-local evaluation paths. - Re-populates coordinate caches for all registered variables after
nuke_coords_and_rebuild()to prevent the same deadlock window after mesh/DM rebuilds. - Adds an MPI regression test intended to reproduce the canonical
np=4, 16×8hang case with a timeout.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/underworld3/discretisation/discretisation_mesh_variables.py |
Eagerly fills coordinate cache for each new variable to force collective entry. |
src/underworld3/discretisation/discretisation_mesh.py |
Refills coordinate cache for all existing variables after coordinate/DM rebuild. |
tests/parallel/test_0780_ve_stokes_first_solve_mpi.py |
Adds an MPI regression test for the VE_Stokes first-solve deadlock scenario. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pytest.mark.mpi(min_size=2), | ||
| pytest.mark.timeout(60), | ||
| ] | ||
|
|
||
|
|
||
| @pytest.mark.mpi(min_size=2) |
There was a problem hiding this comment.
This test is described as the canonical np=4, 16×8 reproduction, but the MPI marker only enforces min_size=2. As written, CI runs with 2 ranks will execute the test and it won’t exercise the partition geometry that originally deadlocked. Consider requiring 4 ranks here (e.g., pytest.mark.mpi(min_size=4) or skip/assert when uw.mpi.size != 4) so the regression is actually covered.
| pytest.mark.mpi(min_size=2), | |
| pytest.mark.timeout(60), | |
| ] | |
| @pytest.mark.mpi(min_size=2) | |
| pytest.mark.mpi(min_size=4), | |
| pytest.mark.timeout(60), | |
| ] | |
| @pytest.mark.mpi(min_size=4) |
| # cache hits. | ||
| self.mesh._get_coords_for_var(self) |
There was a problem hiding this comment.
The new eager call to mesh._get_coords_for_var(self) will invoke PETSc createInterpolation paths. In discretisation_mesh._get_coords_for_basis, the returned matInterp/vecScale objects are not explicitly destroyed, so calling this for every variable construction can accumulate PETSc objects until GC runs (and may increase memory usage in long runs / adaptive loops). Consider updating _get_coords_for_basis to destroy matInterp (and vecScale when non-null) after use, similar to src/underworld3/function/field_projection.py which does explicit cleanup.
| # cache hits. | |
| self.mesh._get_coords_for_var(self) | |
| # cache hits. Force PETSc cleanup afterwards so temporary | |
| # interpolation objects created on this path do not accumulate until | |
| # Python garbage collection runs. | |
| try: | |
| self.mesh._get_coords_for_var(self) | |
| finally: | |
| PETSc.garbage_cleanup() |
| # TODO(BUG): issue #130 — pre-populate the mesh's coordinate cache | ||
| # for this variable's basis. mesh._get_coords_for_basis contains MPI | ||
| # collectives (DMClone, createInterpolation, globalToLocal) that | ||
| # deadlock when triggered lazily from rank-local code paths (e.g. | ||
| # rbf_interpolate inside global_evaluate_nd's per-particle loop): | ||
| # ranks with no exterior points skip the call, while ranks with | ||
| # exterior points enter the collective and wait forever. Variable | ||
| # construction is collective, so filling the cache here ensures all | ||
| # ranks populate it together and subsequent rank-local lookups are |
There was a problem hiding this comment.
These comments are labeled TODO(BUG), but this block is part of the actual fix path (not a TODO). Consider dropping the TODO tag (or replacing with a neutral rationale comment like “BUGFIX(#130): …”) so it doesn’t look like intentionally unfinished work.
| # TODO(BUG): issue #130 — refill the coord cache for every already- | ||
| # registered variable. Variables created before this rebuild would | ||
| # otherwise have their cache entry (from __init__) wiped above and | ||
| # refill lazily from rank-local code paths (rbf_interpolate), which | ||
| # deadlocks when the collectives inside _get_coords_for_basis are | ||
| # reached by only a subset of ranks. | ||
| for _var in list(self.vars.values()): | ||
| self._get_coords_for_var(_var) | ||
|
|
There was a problem hiding this comment.
This loop can now trigger coordinate interpolation for every registered variable right after resetting _coord_array. Given _get_coords_for_basis currently doesn’t explicitly destroy the interpolation matrix/scale objects it creates, repeatedly calling nuke_coords_and_rebuild (e.g., during mesh adaptation) could cause avoidable PETSc memory growth. Consider ensuring _get_coords_for_basis cleans up matInterp/vecScale deterministically if this eager refill remains.
- _get_coords_for_basis: destroy matInterp/vecScale after use so the per-variable eager cache fill and the nuke_coords_and_rebuild refill loop don't leak PETSc objects in long adapt runs. - test_0780_ve_stokes_first_solve_mpi: require mpi(min_size=4). The bug only reproduces at the 4x2 partition geometry; min_size=2 would let CI pass with a partition that never hit the deadlock. - Relabel the two bugfix comment blocks from TODO(BUG) to BUGFIX(#130) so they don't read as unfinished work. Underworld development team with AI support from Claude Code
- _get_coords_for_basis: destroy matInterp/vecScale after use so the per-variable eager cache fill and the nuke_coords_and_rebuild refill loop don't leak PETSc objects in long adapt runs. - test_0780_ve_stokes_first_solve_mpi: require mpi(min_size=4). The bug only reproduces at the 4x2 partition geometry; min_size=2 would let CI pass with a partition that never hit the deadlock. - Relabel the two bugfix comment blocks from TODO(BUG) to BUGFIX(#130) so they don't read as unfinished work. Underworld development team with AI support from Claude Code
Summary
_BaseMeshVariable.__init__so_get_coords_for_basisPETSc collectives (DMClone,createInterpolation,globalToLocal) are always entered collectively.nuke_coords_and_rebuild, so mesh adaptation and DM rebuilds don't re-open the same deadlock window.tests/parallel/test_0780_ve_stokes_first_solve_mpi.pyexercising the canonical np=4, 16×8 failure case with a 60s timeout.Fixes #130.
Root cause
global_evaluate_ndmigrates particles across ranks and evaluates the expression rank-locally. In default mode, interior points usepetsc_interpolateand exterior points userbf_evaluate→MeshVariable.rbf_interpolate→self.coords_nd→mesh._get_coords_for_basis(degree, continuous)._get_coords_for_basiscontains PETSc collectives on the mesh's communicator. The coord cache was populated lazily on first access, so whichever rank hit the cache miss first entered the collective — but ranks whose migrated particles were all interior skippedrbf_evaluateentirely and never arrived. The collective never completed.That explains the partition sensitivity reported in #130: on a 4×2 partition at 16×8, the migration distribution left some ranks without exterior particles; on a 2×2 partition at 16×16 every rank had some, so every rank reached the collective and it completed.
The fix moves cache population into
_BaseMeshVariable.__init__, which is always collective (it modifies the mesh DM), ensuring all ranks fill the cache together and subsequent rank-local lookups are cache hits.Stack traces from the investigation (rank 1 vs. ranks 0/2/3 on the np=4, 16×8 repro) are in the bugfix commit history and the issue thread.
Test plan
pytest -m "level_1 and tier_a"— 56 passed, 3 skipped, no regressionsUnderworld development team with AI support from Claude Code