A rank with no cells must answer every global mesh query like its peers (#405) - #557
Conversation
The gathered table is normally one row per rank, and callers index it by rank. Silently dropping a rank's NaN row shifted every later row up, so the row index no longer matched the rank that contributed it -- the mechanism behind the nearest-centroid particle mis-route on starved ranks (#399), and the trap any empty-rank reduction falls into next (#405). Stripping is still available as strip_nan=True, documented with the reason it is not the default. Underworld development team with AI support from Claude Code
get_min_radius / get_max_radius took min() / max() of the rank's own _radii array. On a rank owning zero cells that array is empty, so the reduction raised a rank-local ValueError while the populated ranks sat in the matching collective -- the job hung or aborted asymmetrically. This fires before the interpolation path is reached, and get_min_radius feeds estimate_dt and the penalty scaling at some fourteen call sites, so a starved rank took down ordinary time-stepping, not just point evaluation. Both accessors now allreduce the local extremum, with an empty rank contributing the identity element (+inf for a MIN, -inf for a MAX), which is the shape get_mean_radius already used. Every rank gets the same global answer, including the starved ones. The same class of defect, fixed here too: - points_in_domain reached get_max_radius (collective) only after a rank-local short-circuit. It now takes the collective first, then answers False everywhere on a rank that owns no cells -- which is the honest answer, and avoids interrogating an empty cell set. - quality() chose between its simplex and volume-only branches from rank-local evidence. A starved rank collects no triangles, so it ran three reductions where its peers ran eleven: mismatched collective counts, i.e. a hang on any simplex mesh with an empty rank. The branch is now decided collectively and each reduction is empty-safe. - physical_bounds / physical_extent reduced over this rank's nodes only, so every rank reported a different "domain size" and an empty rank raised. Both now share _global_coord_bounds, which reduces across ranks. A suspected double application of the length scale in that path is flagged with a TODO(BUG) rather than changed here. Underworld development team with AI support from Claude Code
A rank owning no cells owns no DOFs, so there is nothing to interpolate from. MeshVariable.rbf_interpolate built its kd-tree over an empty point cloud and the monotone limiter's kNN bound gathered neighbour values from an empty array (#405). The SwarmVariable rbf path already guarded this way. Both are purely rank-local, so returning early takes no collective with it: rbf_interpolate returns correctly-shaped zeros, and the limiter returns the (empty) value unchanged -- exact, not a fallback. monotone='pick', the only collective mode, is already refused under MPI. Underworld development team with AI support from Claude Code
Three reductions inside the timestep estimators read this rank's own cells and reduced before the allreduce, so a starved rank raised while its peers waited in the collective (#405): - _global_max_diffusivity took .max() of the diffusivity sampled at this rank's cell centroids. The max is now taken once, in the reduction, where an empty rank contributes the identity element. - SNES_NavierStokes.estimate_dt took .max() of the centroid velocity magnitudes. - SNES_Stokes_SaddlePt.estimate_dt took .max() of the local velocity DOFs. Underworld development team with AI support from Claude Code
A 1x2 quad box has two cells, so PETSc must leave at least one rank empty at both np=2 and np=4. The tests assert what the fix is for: min/max/mean radius, quality(), estimate_dt and points_in_domain give every rank the same global answer, equal to the serial one, with no hang. Two of them earn their keep explicitly. The premise test fails if the partition is not actually starved, and the negative control shows the cross-rank agreement assertion has teeth: the rank-local minima on this partition do NOT agree, so a rank-local answer would be caught. Fail-before on the same fixture (build without the fix): at np=2 one rank raised "zero-size array to reduction operation minimum which has no identity" from get_min_radius while the other passed, then the run HUNG; at np=4 three ranks raised and the run HUNG. Both needed the mpirun timeout to end. Also records a measured finding in mesh_metric_mismatch: the empty-rank limit on field-valued metrics does NOT lift with this fix, as #405 item 2 expected. Measured at np=4 on a starved mesh, the remaining blocker is below UW3 -- the DMPlex sub-DM clone the mesh-variable path builds fails with MPI_ERR_BUFFER when a rank has no cells. That is #314's territory. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR fixes MPI deadlocks and asymmetric failures when a rank owns zero cells by ensuring “global” mesh queries and reduction-based diagnostics use collective reductions with identity elements (rather than rank-local .min()/.max() on empty arrays), and by adding a targeted parallel regression suite for issue #405.
Changes:
- Make global reductions (min/max radius, max diffusivity, max velocity magnitude, global bounds) empty-rank safe by contributing identity elements and still participating in collectives.
- Reorder/guard evaluation helpers (
points_in_domain,quality(), interpolation/limiter helpers) to avoid rank-asymmetric control flow around collectives. - Add
tests/parallel/test_0774_empty_rank_reductions_mpi.pyto prevent regressions on starved partitions (np=2 and np=4).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/parallel/test_0774_empty_rank_reductions_mpi.py | New MPI regression tests covering radii, points_in_domain, quality, estimate_dt, and gather_data rank-row preservation on empty ranks. |
| src/underworld3/utilities/_utils.py | Changes gather_data semantics to keep NaN rows by default; adds strip_nan opt-in and expands documentation. |
| src/underworld3/systems/solvers.py | Makes _global_max_diffusivity and Navier–Stokes estimate_dt reductions empty-safe. |
| src/underworld3/meshing/smoothing/metrics.py | Updates KNOWN LIMIT note with measured behavior and points remaining blocker to #314. |
| src/underworld3/function/functions_unit_system.py | Adds an empty-input early return in the monotone limiter to avoid indexing empty source clouds. |
| src/underworld3/discretisation/discretisation_mesh.py | Fixes empty-safe min/max radii; enforces collective ordering in points_in_domain; makes quality() branch choice collective; introduces _global_coord_bounds() for global physical bounds/extent. |
| src/underworld3/discretisation/discretisation_mesh_variables.py | Guards rbf_interpolate against empty DOF data by returning correctly shaped zeros. |
| src/underworld3/cython/petsc_generic_snes_solvers.pyx | Makes Stokes saddle-point estimate_dt velocity-max reduction empty-safe. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Returns | ||
| ------- | ||
| numpy.ndarray | ||
| The concatenated contributions from all ranks, in rank order. |
| if strip_nan and uw.mpi.rank == 0: | ||
| val_global = val_global[~np.isnan(val_global)] | ||
|
|
CI regression from this branch: tests/test_1005_TransientDarcyCartesian.py failed with "TypeError: Could not convert object to sequence" out of SNES_TransientDarcy.solve. Diagnosis. Every estimate_dt funnels its nondimensional result through np.squeeze. That collapses a numpy scalar to a numpy scalar, but PROMOTES a plain Python float to a 0-d ndarray. solve(timestep=dt) then evaluates `timestep != self.delta_t` against a UWexpression, and ndarray-vs-sympy comparison tries to convert the sympy object to a sequence and raises rather than deferring to sympy's own comparison. The contract only ever held by accident: get_min_radius returned a numpy scalar, so the squeeze was a no-op. Making it return a plain float (the empty-rank work in this branch, where the reduction is now an allreduce of a Python float) turned the squeeze into a promotion and broke the solve. Fix at the contract boundary rather than by restoring the accident: the shared _apply_unit_aware_scaling helper already documents its return as "float or UWQuantity", so it now collapses a 0-d array to a scalar via a small _as_scalar helper. Unit-bearing values are passed through untouched -- .item() would discard their units. The two estimate_dt implementations that return directly instead of via that helper (SNES_Stokes, SNES_AdvectionDiffusion) use the same helper, so all five agree. This makes the contract independent of whichever numeric type a caller passes in. tests/test_1007 pins it: _as_scalar collapses 0-d arrays and only those, and TransientDarcy.estimate_dt returns something solve() actually accepts. Measured on the broken build, estimate_dt returned array(0.01586914) with ndim 0; it now returns the identical value as a float. Underworld development team with AI support from Claude Code
|
CI regression diagnosed and fixed in
Fixed at the contract boundary rather than by restoring the accident: Incidental catch: Re-ran the batches this diff can reach that the tier-A gate does not (they are the exposure via For a reviewer: the part of this PR to look at hardest is the radius accessors' return type, consumed at ~14 sites — that is what had the blast radius here. Underworld development team with AI support from Claude Code |
Empty-rank support, layer 2: a rank with no cells gets the same global answer as its peers
Fixes #405.
The defect
A rank that owns zero cells raised a rank-local
ValueErrorfrom an unguardedlocal reduction —
self._radii.min()on an empty array — while its populated peerssat in the matching collective. The job then hung, or aborted asymmetrically.
This is distinct from #314's collective-count divergence and fires earlier, before
the interpolation path is reached. It matters because
get_min_radiusfeedsestimate_dtand the penalty scaling at ~14 call sites and sits underSwarm.migrateas well as under
evaluate: a starved rank took down ordinary time-stepping, not justpoint evaluation.
The correctness argument
Every quantity here is global — the smallest cell anywhere, the domain bounding
box, the largest diffusivity. It happens to be computed from rank-local data, so it
must be reduced across ranks, and a rank with no cells must contribute the identity
element of that reduction (
+inffor a MIN,-inffor a MAX,0for a SUM) ratherthan raise. Every rank then calls the collective and every rank — starved or not —
returns the same value. That is the shape
get_mean_radiusalready used; the fixcopies it.
Two supporting rules the diff follows throughout:
(
points_in_domainon an empty rank is honestly all-False), the collective istaken first, by every rank, and the short-circuit comes after.
it. The two interpolation guards are of this kind, and say so.
Sites fixed
From the issue's own list (all verified still present at
development@e475246f):discretisation_mesh.py—get_min_radius/get_max_radius_radii.min()/.max()on an empty array±infdiscretisation_mesh.py—points_in_domainget_max_radius; then interrogated an empty cell setFalseon a zero-cell rankutilities/_utils.py—gather_datastrip_nan=True, off by default and documentedfunction/functions_unit_system.py— monotone kNN limiterdiscretisation/discretisation_mesh_variables.py—rbf_interpolatemeshing/smoothing/metrics.py—mesh_metric_mismatchFound beyond the list, same class (a global quantity computed from rank-local data):
Mesh.quality()chose between its simplex and volume-only branches fromrank-local evidence. A starved rank collects no triangles, so it ran three
reductions where its peers ran eleven — mismatched collective counts, i.e. a hang
on any simplex mesh with an empty rank. The branch is now decided collectively and
every reduction is empty-safe. (The quad-box fixtures escaped this by luck: both
branches coincide there.)
Mesh.physical_bounds/physical_extentreduced over this rank's nodes only,so every rank reported a different "domain size", and an empty rank raised. Both now
share a new
_global_coord_bounds()which allreduces the box. Kept behaviour-neutralotherwise: a suspected double application of the length scale in that path is flagged
with a
TODO(BUG)rather than changed here.estimate_dt, three implementations:_global_max_diffusivity(.max()of thediffusivity sampled at this rank's centroids),
SNES_NavierStokes.estimate_dt(
.max()of centroid velocity magnitudes), andSNES_Stokes_SaddlePt.estimate_dtin the.pyx(.max()of the local velocityDOFs). All three reduced before the allreduce, so a starved rank raised while its
peers waited. These are the priority path named in the issue.
Assessed and deliberately left:
SNES_AdvectionDiffusion.estimate_dtandSNES_Stokes.estimate_dtper-elementreductions — already
len()-guarded, verified empty-safe.Swarm.estimate_dt— already has a sanctionedexcept (ValueError, IndexError)contributing
0.0to the MAX._get_domain_centroids— already guarded (Mesh construction crashes on empty ranks: navigation kd-tree build passes a 1-D empty array (StructuredQuadBox(elementRes=(2,2)) at np4) #399) with a finite sentinel. Only itscomment changed, to record that
gather_datano longer strips NaN but the sentinelis still required (a NaN row would poison the kd-tree).
quality()'s percentiles and neighbour size-jump — rank-local estimates bydocumented design; they now return
NaNon a rank with no local distribution ratherthan raising.
mesh._radiiper-cell loop in_get_mesh_sizes,stats()(PETScVec.max()isalready global), the XDMF connectivity check (rank-0, file data).
Item 2 does not lift — measured, not assumed
The issue expected
mesh_metric_mismatch's starved-rank limit on field-valued metricsto lift automatically once the reduction layer was safe. It does not. Measured at np=4
on a starved mesh with this fix in place:
get_min_radius,get_max_radius,get_mean_radius,points_in_domain,quality()andestimate_dtall succeed andagree, but any path that builds a mesh-variable sub-DM fails below UW3 with
MPI_ERR_BUFFERout ofDMCreateSubDM_Plex→DMClonewhen a rank has no cells. TheKNOWN LIMIT comment now records that, names #314 as the remaining blocker, and says
what was measured.
Fail-before evidence
Same test file, same fixture, on a build with the fix stashed out:
test_radius_accessors_are_global_on_a_zero_cell_rankFAILED on onerank only with
ValueError: zero-size array to reduction operation minimum which has no identity(raised atdiscretisation_mesh.py:6512,get_min_radius), theother rank did not fail, and the run then HUNG at the next test. Ended by the
mpirun --timeoutguard.guard.
That asymmetry is the bug: one rank raises, the rest are still in the collective.
With the fix: 7 passed at np=2 and 7 passed at np=4.
Tests
tests/parallel/test_0774_empty_rank_reductions_mpi.py(7 tests,level_1+tier_a,pytest.mark.timeout(120)— the pre-fix failure mode is a hang, so everytest is timeout-guarded).
The fixture is
StructuredQuadBox(elementRes=(1, 2)): two cells, so PETSc must leave arank empty at both np=2 (
[2, 0]) and np=4 ([0, 0, 0, 2]).Two tests exist to keep the others honest:
test_premise_some_rank_owns_no_cellsfails if the partition is not actuallystarved, so the suite cannot quietly stop testing the thing it is named for.
test_negative_control_rank_local_minimum_would_be_caught(house negative-controlrule) computes the rank-local minimum on this partition and asserts the ranks do
not agree — proving the cross-rank agreement assertion is not true by
construction, and would fire if
get_min_radiusever returned a rank-local answer.Oracle for the value: the reported characteristic length is the cell's
centroid-to-corner half-diagonal,
sqrt(0.5² + 0.25²)for this fixture — analytic, nota recorded number. Matches the np=1 value to 1e-9.
Verification
test_0774_empty_rank_reductions_mpi.pynp=2test_0774_empty_rank_reductions_mpi.pynp=4ptest_0008_mesh_radii_accessors.pynp=2 / np=4test_0700_basic_parallel_operations.pynp=2 / np=4test_0750_global_statistics.pynp=2 / np=4test_0755_swarm_global_stats.py(agather_dataconsumer) np=2 / np=4tests/test_1018_rotated_freeslip.pypytest tests -m "level_1 and tier_a" -q --ignore=tests/test_0050_utils.pyUnblocks #314
#314 (
global_evaluatenp=4 hang on empty-interior ranks) is the same family seen fromthe DMInterpolation side, and could not be worked while the layer beneath it raised
first: any starved-rank reproducer died in
get_min_radiusbefore reachingDMInterpolation. That layer is now clean, and the measurement above localises what isleft —
DMCreateSubDM_Plex→DMClonereturningMPI_ERR_BUFFERon a zero-cell rank —which is a concrete starting point for #314 rather than a symptom. #512's item 1 should
fall out of the same work.
Underworld development team with AI support from Claude Code