Skip to content

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

Open
lmoresi wants to merge 1 commit into
developmentfrom
bugfix/adapt-collective-followups
Open

The adapt level loop stops on a collective fact, not a rank-local one#596
lmoresi wants to merge 1 commit into
developmentfrom
bugfix/adapt-collective-followups

Conversation

@lmoresi

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 points0.06 s0.06 s
stranded points0.06 s0.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
CopilotAI lite review requested due to automatic review settings August 17, 2026 08:18

CopilotAI 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

Copy link
Copy Markdown
MemberAuthor

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

Sign up for freeto 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

@lmoresi