The adapt level loop stops on a collective fact, not a rank-local one - #596
Conversation
Each level of the marking loop ends with a DM refinement, which is collective, so leaving that loop is a decision every rank takes together. The sbr path broke on `refine.size == 0` — this rank's own cells are all fine enough, which says nothing about its peers'. The rank that finished first left for good and the others went round again into the refinement and waited. Measured on development at np=2: a callable metric demanding h=0.01 within r<0.15 of one corner and nothing elsewhere, 132 cells per rank, hung with neither rank returning; mpirun reached its timeout. Fixed, sbr returns (2421/132 cells), as do edge_split and nvb. No empty rank is needed and the metric is a callable, so global_evaluate is not involved. #512 attributes the hazard instead to `eval_metric` being global_evaluate and therefore collective, with a cell-less rank skipping it. Measured, that is not so: with one rank asleep for 10 s the other's global_evaluate returned in 0.06 s, for in-domain points and for points stranded outside the mesh alike. The rank-local `if cur_h.size:` guards were not hanging. They now route through `marking_metric` for uniformity with the stop, and the docstring says which of the two the measurement supports. The pure-Python nvb cell-list engine keeps its rank-local marking and break, with a comment: it raises NotImplementedError at np>1, so it is serial by construction and has nothing to protect. reconnect: the "a shared point was deleted" guard is assert-class but was raised rank-locally, which would hang the peers it was trying to inform. It is now a collective verdict naming the offending ranks. The emptiness test is read before the verdict and acted on after, so a rank that shares nothing still reaches it. test_pinned_set_is_partition_independent compared len(union) with allreduce(len(union), MAX) over a union every rank builds from the same gathered list — equal by construction, unable to fail. Replaced by an assertion that can: every rank pins every band vertex present in its own coordinate array. Closes #512. Underworld development team with AI support from Claude Code
Adversarial reviewReviewed at 1. 2. The 0.06 s measurement bounds the call shapes we tried, not 3. The premise test constrains the fixture, not the defect. Checked and clean: the Underworld development team with AI support from Claude Code |
Responses to the review abovePositions on the three findings, so none is left open. 1. 2. The 0.06 s measurement bounds the shapes we tried. Agreed, and it is 3. The premise test constrains the fixture, not the defect. Standing, and it Underworld development team with AI support from Claude Code |
) * Run the whole parallel directory, and run it at four ranks as well as two Two scripts, two different holes. scripts/test.sh (what CI runs) used tests/parallel/test_075*py and test_10*py; scripts/test_levels.sh (what ./uw test runs) used tests/parallel/test_07*py. Of the 32 collectible files in that directory, test.sh named 14 and missed test_0005, test_0700, test_0760..test_0790, test_0855 and test_0873; test_levels.sh missed test_0005, test_0855, test_0873 and the whole test_10* solver set. Three files therefore ran at NO rank count in either script: test_0005, test_0855, and test_0873 — the last added two days ago in #596 to guard against a parallel hang. Both now name the directory. A glob that names ranges grows holes as files are added between them, which is the #570 class and is how #611 survived unnoticed. Both scripts also run the set at four ranks. Two ranks is a special case: the defect this suite exists to catch is a collective entered by some ranks and not others, and with two the mismatched pair often still meets. Every instance found recently passed at np=2 and hung at np=4 — the conditional collective in #609, and #611 itself. test_levels.sh already had --full-parallel for this and was pointing it at the narrower glob. Measured on this directory, machine otherwise idle: np=2 128 passed, 11 skipped 156 s np=4 135 passed, 3 skipped 170 s (1 deselected) end to end, scripts/test.sh --p 2: 135 passed, 3 skipped, 1 deselected, 198 s for the parallel section, rc=0. The skip counts differ because some tests require four ranks and skip at two, which is a second reason to run both. The single deselection is #611: test_global_evaluate_after_migration passes at np=2 and hangs at np=4 on development. Its node id carries no `tests/` prefix because tests/pytest.ini puts rootdir at `tests/`; a deselect that does not match is ignored in silence, which cost two wrong diagnoses while measuring this. Underworld development team with AI support from Claude Code * Make the four-rank pass opt-in: it does not fit the CI budget Run unconditionally, the second parallel pass took the test job past the 120-minute cap. Recent runs on this repo take 26 to 40 minutes, so there was about 80 minutes of headroom; on a two-core runner np=4 is oversubscribed and the pass costs far more than the ~3 minutes it takes on a workstation. It is now behind --full-parallel, off by default, which is the same shape test_levels.sh already used for its own four-rank pass. CI keeps the part that matters and costs little: the whole tests/parallel/ directory at the requested rank count, which is what closes the glob hole. Verified: ./scripts/test.sh --p 2 --parallel-only exits 0 and runs no four-rank pass. Underworld development team with AI support from Claude Code * Cover tests/parallel by enumeration, and keep the batching Two requirements that pull against each other, and the previous commit met only one. Coverage: the globs `test_075*py` and `test_10*py` named 14 of the 32 files and left a hole from 0760 to 0999, so test_0760, test_0765..test_0790, test_0855 and test_0873 ran in parallel at no rank count. The list is now enumerated from the directory, so it covers by construction rather than by ranges that grow holes as files are added between them. Batching: this script does not run one monolithic pytest, for the reason in its own header — PETSc objects accumulate across files and tests begin to interact. Handing the whole directory to a single mpirun took the CI test job past its 120-minute cap twice. The enumeration is therefore chunked, PARALLEL_BATCH files at a time, default 6. Measured in-environment at np=2: 32 files, 128 passed, 180 s across 6 batches. The four-rank pass stays opt-in behind --full-parallel, where "1 deselected" confirms the #611 exclusion matches. Underworld development team with AI support from Claude Code --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #512.
Three follow-ups from the #488 re-review. The first turned out to be a live
hang rather than a latent one, and for a different reason than the issue gives.
The stop, not the metric
Each level of the adapt marking loop ends with a DM refinement, which is
collective. Leaving that loop is therefore a decision every rank has to take
together. The sbr path broke on
refine.size == 0— this rank's own cells areall fine enough — which says nothing about its peers'. The rank that ran out of
work left for good; the others went round again into the refinement and waited.
Measured on
developmentat np=2, with a callable metric demanding h = 0.01within r < 0.15 of one corner and nothing elsewhere, on 132 cells per rank:
and with the fix:
No empty rank is required, and the metric is a callable — evaluated rank-locally
— so
global_evaluateis not involved.What the issue attributes it to, and what we measured
#512 describes the rank-local
if cur_h.size:guards as skipping a collective,on the grounds that
eval_metricisglobal_evaluatefor a field or expressionmetric. That is not what happens. With one rank asleep for ten seconds, the
other's
global_evaluatereturned in 0.06 s — for in-domain points, and forpoints stranded outside the mesh entirely, which is the branch that allgathers:
It does not wait for its peers on these shapes, so a cell-less rank skipping it
does not hang. Those guards now route through a
marking_metrichelper foruniformity with the collective stop, and its docstring records which of the two
readings the measurement supports — working from the issue's premise, the next
person would "fix" sites that were never broken.
The pure-Python nvb cell-list engine keeps its rank-local marking and break, with
a comment saying why: it raises
NotImplementedErrorat np>1, so it is serial byconstruction.
reconnect.py — a collective verdict
The "a shared point was deleted" guard is assert-class, but raising it on one
rank while the others carry on into the next collective hangs the peers it is
trying to inform. It is now an allgathered verdict naming the offending ranks.
Note the shape of the fix: the emptiness test above it is read early and
acted on after the verdict. Putting the allgather where the guard was would
have left a rank that shares nothing returning before it — introducing exactly
the defect being fixed.
The vacuous test
test_pinned_set_is_partition_independentallgathered the pinned coordinates,took the union, and asserted
len(union) == allreduce(len(union), MAX). Everyrank builds that union from the same gathered list, so the two numbers are equal
by construction and the assertion could not fail whatever the pinning did.
Replaced with one that can fail: every rank must pin every band vertex present in
its own coordinate array, so a rank-local pin that misses a vertex a peer pinned
is caught — which is the defect class, since the owner is then free to move a
vertex the seam expects to stay.
Verified
test_0873_adapt_collective_stop_mpi.py: 4 passed at np=2, and withptest_0845, 7 passed at np=4. It carries a premise test asserting the metricreally does leave a rank with no work — without that, a passing run says only
that adapt returned.
./uw test: 1509 passed, 32 skipped, 2 xfailed.Underworld development team with AI support from Claude Code