Swarm.migrate: shrink the working set instead of reclassifying every round - #609
Conversation
…round
The round loop re-offered the whole local array to points_in_domain on every
round, so the classification work per rank grew with the round count. Measured
on a fixed 47k-point global set through global_evaluate, points offered to
points_in_domain: 47k at np=1, 142k at np=2, 238k at np=4.
A point this rank has found to be in its domain stays in its domain — neither
the coordinates nor the mesh change during migration — so it does not need
retesting. The loop now remembers what it has claimed and classifies only the
rest.
The claimed set is keyed by COORDINATE rather than by index. dm.migrate does
not preserve the local ordering: measured, retained points are NOT left at the
front, so an index from the previous round names a different particle after the
move. Two particles sharing a coordinate share the answer, so a key collision
is harmless. Exact bit-pattern comparison, vectorised through a void row view
(15 ms against points_in_domain's 58 ms at 47k, so it pays whenever more than
about a quarter of the local array is already claimed).
Measured after, same probe:
np global_evaluate points offered to points_in_domain
1 0.208 -> 0.213 s 46 901 -> 46 901
2 0.326 -> 0.232 s 141 516 -> 47 558
4 0.443 -> 0.257 s 237 826 -> 50 025
42% off global_evaluate at np=4, and the growth with rank count is gone: the
classification now sees roughly the local set once whatever np is. The
nearest-centroid walk shrinks with it, since it is called from inside the
classification.
The call to points_in_domain is UNCONDITIONAL. It is collective — it reaches
get_max_radius() before any short-circuit precisely so a rank with nothing to
classify still joins the reduction (#405) — and an earlier draft of this
skipped it when a rank had no undecided points, which deadlocked at np=4 with
every rank inside the call.
Verified behaviour-preserving rather than merely green: the per-rank partition
fingerprint after migration is byte-identical to development at np=2 and np=4.
Full ./uw test 1556 passed, matching development.
test_0776 covers the loop, with a premise test that the fixture actually
migrates anything, and its own negative control: reintroducing the conditional
collective makes it time out at np=4 (rc=241), and the fix makes it pass.
Underworld development team with AI support from Claude Code
Adversarial reviewReviewed at 1. The claimed set grows without bound within a call, and is rebuilt on every 2. 3. The equivalence evidence covers np=2 and np=4 on one mesh. The partition 4. The premise test constrains the fixture, not the property. Checked and clean. The Underworld development team with AI support from Claude Code |
The np=4 parallel-test hang is pre-existing, not from this PRReporting this because an earlier run made it look otherwise. Running One earlier np=4 result on this branch should be discarded rather than trusted: What stands for this PR: full |
tests/parallel/test_0776_linear_rbf_proxy_parallel.py already exists, so the new file collided with it — the same defect as #600, where two subjects ended up sharing 1029. Caught while auditing which parallel files scripts/test.sh actually runs. 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>
Addresses the dominant term in #551.
What was costing the time
Swarm.migrate's round loop re-offered the whole local array topoints_in_domainon every round, so the classification work per rank grew withthe round count. Measured through
global_evaluateon a fixed 47k-point globalset (2-D simplex,
cellSize=1/100, identical total work at every rank count):global_evaluatepoints_in_domainThe walk holds a roughly constant share. What grows is the classification, from
28% to 58% of the call, because a fixed 47k-point set is offered to it 238k
times at np=4.
The change
A point this rank has found to be in its domain stays in its domain — neither
the coordinates nor the mesh change during migration — so it does not need
retesting. The loop remembers what it has claimed and classifies only the rest.
Keyed by coordinate, not by index.
dm.migratedoes not preserve the localordering: measured, retained points are not left at the front, so an index from
the previous round names a different particle after the move. Two particles at
the same coordinate share the answer, so a key collision is harmless. Exact
bit-pattern comparison through a void row view — 15 ms against
points_in_domain's 58 ms at 47k, so it pays whenever more than about a quarterof the local array is already claimed, which it is from the second round on.
Measured after
global_evaluatepoints_in_domain42% off at np=4, and the growth with rank count is gone — the classification now
sees roughly the local set once whatever np is. np=1 is unchanged within noise
(one extra membership pass, no rounds to save). The walk shrinks with it, since
it is called from inside the classification.
This is the larger of the two faults named in #551's "amplifier" section. The
other — no cheap rejection inside the walk — attacks the smaller, flatter term
and is untouched here.
The collective
The
points_in_domaincall is unconditional, and that is load-bearing.It is collective — it reaches
get_max_radius()before any short-circuitprecisely so a rank with nothing to classify still joins the reduction (the #405
treatment, stated in its own source). Guarding it on whether this rank has
undecided points deadlocks at np=4, with every rank inside the call, as soon as
one rank runs out of them.
Verified
partition fingerprint after migration — the sorted coordinates each rank owns,
hashed — is byte-identical to
developmentat np=2 and np=4../uw test: 1556 passed, 32 skipped, 2 xfailed, matchingdevelopment.test_0776_migrate_working_set_mpi.pycovers the loop: the global particlecount is conserved, every point lands on a rank whose mesh contains it, and the
global multiset of coordinates is unchanged. It carries a premise test that the
fixture actually migrates anything —
populatealone leaves every particlealready owned, and the loop never runs.
test_0776time out at np=4 (rc=241); restoring the fix makes it pass. Thetest catches the defect it was written for.
Underworld development team with AI support from Claude Code