Skip to content

The adapt level loop stops on a collective fact, not a rank-local one - #596

Merged
lmoresi merged 1 commit into
developmentfrom
bugfix/adapt-collective-followups
Aug 18, 2026
Merged

lmoresi merged 1 commit into
developmentfrom
bugfix/adapt-collective-followups

Conversation

@lmoresi

@lmoresi lmoresi commented Aug 17, 2026

Copy link
Copy Markdown
Member

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 are
all 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 development at np=2, with a callable metric demanding h = 0.01
within r < 0.15 of one corner and nothing elsewhere, on 132 cells per rank:

[1] entering adapt
[0] entering adapt
<mpirun reached its timeout; neither rank returned>

and with the fix:

[1] ADAPT RETURNED cells=132
[0] ADAPT RETURNED cells=2421

No empty rank is required, and the metric is a callable — evaluated rank-locally
— so global_evaluate is 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_metric is global_evaluate for a field or expression
metric. That is not what happens. With one rank asleep for ten seconds, the
other's global_evaluate returned in 0.06 s — for in-domain points, and for
points stranded outside the mesh entirely, which is the branch that allgathers:

rank 0 (sleeps 10 s first) rank 1 (calls immediately)
in-domain points 0.06 s 0.06 s
stranded points 0.06 s 0.06 s

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_metric helper for
uniformity 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 NotImplementedError at np>1, so it is serial by
construction.

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_independent allgathered the pinned coordinates,
took the union, and asserted len(union) == allreduce(len(union), MAX). Every
rank 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 with
    ptest_0845, 7 passed at np=4. It carries a premise test asserting the metric
    really does leave a rank with no work — without that, a passing run says only
    that adapt returned.
  • Full ./uw test: 1509 passed, 32 skipped, 2 xfailed.

Underworld development team with AI support from Claude Code

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
Copilot AI lite review requested due to automatic review settings August 17, 2026 08:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@lmoresi

lmoresi commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Adversarial review

Reviewed at 94d35c7. Three findings.

1. marking_metric adds a collective per level to two engines that did not
need one.
The measurement in the description says the if cur_h.size: guards
were not hanging, so routing nvb-native and edge_split through the helper buys
uniformity and costs an allreduce per level per engine — on a 4-level adapt at
np=8 that is a handful of reductions against a refinement pass, so we judged it
free, but it is a real addition made for tidiness rather than for a defect. The
sbr path genuinely needs it, since that is where the collective stop reads
"nobody has cells".

2. The 0.06 s measurement bounds the call shapes we tried, not
global_evaluate.
We drove it with a rank asleep and the other calling, for
in-domain and stranded points, at np=2. global_evaluate_nd does contain
uw.mpi.barrier() and an allgather/Allreduce ladder on the best-claim path;
what we can say is that neither of the shapes we could construct entered them in
a way that blocked a peer. A different metric, a different partition, or np>2
could still reach them. We would rather the code did not depend on which — hence
finding 1 being a cost we accepted rather than an error.

3. The premise test constrains the fixture, not the defect.
test_premise_the_metric_splits_the_ranks asserts some rank has no work at the
FIRST level. The hang needs a rank to run out of work at some level while
another continues, which the first level happens to give on this fixture at
np=2 and np=4. On a partition where every rank holds corner cells the parametrised
tests would pass without exercising anything — the premise test then fails
loudly, which is the right outcome, but it means the coverage is
partition-dependent and we have checked it only at np=2 and np=4.

Checked and clean: the reconnect.py verdict is reached by every rank. The
emptiness test above it is read before the allgather and acted on after, so a
rank that shares nothing does not return early past the collective — which is
the mistake the first draft of that fix made.

Underworld development team with AI support from Claude Code

@lmoresi

lmoresi commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Responses to the review above

Positions on the three findings, so none is left open.

1. marking_metric adds a collective to two engines that did not need one.
Keeping it, and narrowing the claim rather than the code. The measurement says
the nvb-native and edge_split guards were not hanging, so routing them through
the helper is uniformity, not a fix — the docstring already says which of the
two the measurement supports. The cost is one allreduce of a single int per
level per engine, against a DM refinement in the same iteration. Reverting those
two would leave three marking loops with two different emptiness disciplines,
and the sbr one genuinely needs the collective form, so the reader would have to
know which is which. That is a worse failure mode than a reduction nobody
notices.

2. The 0.06 s measurement bounds the shapes we tried. Agreed, and it is
stated that way in the note and the docstring. global_evaluate_nd does contain
uw.mpi.barrier() and an Allreduce ladder on the best-claim path; what we
established is that neither an in-domain call nor a stranded-point call at np=2
blocked a peer. We are not claiming the function is never collective, only that
the mechanism #512 names is not the one that hangs the adapt loop. Finding 1's
answer is the hedge against being wrong about this.

3. The premise test constrains the fixture, not the defect. Standing, and it
is the reason the premise test exists. It asserts some rank has no work at the
first level; if a partition gave every rank corner cells the parametrised tests
would pass without exercising anything, and the premise test then fails loudly
rather than the suite going quietly green. Verified at np=2 and np=4 only — a
run at np=8 or on a different partitioner could need the metric's radius
adjusted, and the failure would name itself.

Underworld development team with AI support from Claude Code

@lmoresi
lmoresi merged commit 6cda9c6 into development Aug 18, 2026
2 checks passed
@lmoresi
lmoresi deleted the bugfix/adapt-collective-followups branch August 18, 2026 07:14
lmoresi added a commit that referenced this pull request Sep 4, 2026
)

* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants