Skip to content

Commit 40b3840

Browse files
author
Han Wang
committed
test(lmp): bridged DPA4 2-rank parity with a cross-boundary close pair
Replaces the fails-fast test: the with-comm artifact now completes the SFPG partials across ranks, so a 2-rank run with the 0.9 A pair straddling the processors 2 1 1 boundary must (and does) match the 1-rank reference (issue deepmodeling#5906 Task 2 E2E).
1 parent 28588e6 commit 40b3840

1 file changed

Lines changed: 86 additions & 66 deletions

File tree

source/lmp/tests/test_lammps_dpa4_zbl_pt2.py

Lines changed: 86 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,19 @@
1212
handling, per-atom virial accumulation and unit conversion), so a regression
1313
confined to it was invisible.
1414
15-
Single-rank only, deliberately
16-
------------------------------
15+
Multi-rank correctness (issue #5906)
16+
------------------------------------
1717
Bridging enables the descriptor's Source Freeze Propagation Gate, whose
1818
per-node ``eta_j = prod_{e: src_e = j} w_e`` folds a node's FULL outgoing-edge
19-
set. Edges exist only for owned centres, so eta is incomplete on every rank
20-
and the freeze exports NO with-comm artifact (``gen_dpa4_zbl.py`` asserts
21-
``has_comm_artifact is False`` and that no nested
22-
``forward_lower_with_comm.pt2`` entry exists). There is therefore no
23-
correct multi-rank answer to compare against; what this file pins instead is
24-
that a multi-rank run FAILS LOUDLY rather than silently returning
25-
wrong-but-plausible numbers -- see
26-
``test_pair_deepmd_mpi_dpa4_zbl_fails_fast``.
19+
set. Edges exist only for owned centres, so the per-node partials are
20+
rank-incomplete; the with-comm artifact completes them with one
21+
reverse-accumulate + forward-broadcast border exchange before the gate is
22+
applied (``gen_dpa4_zbl.py`` asserts ``has_comm_artifact is True`` and that
23+
the nested ``forward_lower_with_comm.pt2`` entry exists). This file pins the
24+
end-to-end contract with a 2-rank vs 1-rank parity run whose sub-``r_outer``
25+
close pair STRADDLES the ``processors 2 1 1`` x-boundary -- without that
26+
geometry every cross-rank gate contribution is ``log w = 0`` and the parity
27+
holds vacuously -- see ``test_pair_deepmd_mpi_dpa4_zbl_close_pair_parity``.
2728
2829
Reference values are computed LIVE at test-setup time via
2930
``deepmd.infer.DeepPot.eval`` on the archive itself, mirroring
@@ -72,13 +73,13 @@
7273
data_file = Path(__file__).parent / "data_dpa4_zbl_pt2.lmp"
7374
# The MPI runner is backend-agnostic (DATAFILE PB_FILE OUTPUT + flags); reuse
7475
# the DPA3 driver verbatim rather than duplicate it (same pattern as
75-
# test_lammps_dpa4_graph_pt2.py). Only the fail-fast test below uses it.
76+
# test_lammps_dpa4_graph_pt2.py). Only the multi-rank tests below use it.
7677
mpi_runner = Path(__file__).parent / "run_mpi_pair_deepmd_dpa3_pt2.py"
7778

78-
# Ceiling for the mpirun invocation. The fail-fast under test is expected to
79-
# throw on EVERY rank before any collective, so a timeout means the guard
80-
# regressed into a deadlock -- which is a test failure, not a slow machine.
81-
_MPI_DEFAULT_TIMEOUT = 300.0
79+
# Ceiling for every mpirun invocation: a with-comm desync hangs the
80+
# collective forever, so an unbounded should-succeed regression would hang
81+
# the whole suite -- a timeout is a test failure, not a slow machine.
82+
_MPI_DEFAULT_TIMEOUT = 600.0
8283

8384
# 6-atom NiO system, coordinates verbatim from
8485
# ``source/tests/infer/gen_dpa4_zbl.py``'s ``_COORDS``: atoms 0 and 1 sit
@@ -101,6 +102,16 @@
101102
# -> LAMMPS types [1,1,1,2,2,2] under identity ``pair_coeff * *``.
102103
type_NiO = np.array([1, 1, 1, 2, 2, 2])
103104

105+
# Close-pair variant for the 2-rank parity test (issue #5906): the SAME
106+
# 6-atom geometry shifted along x so the 0.9 A Ni-Ni pair (inside the
107+
# bridging window, r_inner=0.8 < 0.9 < r_outer=1.2) STRADDLES the
108+
# ``processors 2 1 1`` boundary at lx/2 = 6.5. Atom 0 lands at x = 6.3
109+
# (rank 0) and atom 1 at x = 7.2 (rank 1), so the pair's SFPG gate
110+
# contribution crosses the rank boundary -- the load-bearing geometry.
111+
_CLOSE_PAIR_X_SHIFT = 5.3
112+
coord_close_pair = coord + np.array([_CLOSE_PAIR_X_SHIFT, 0.0, 0.0])
113+
data_file_close_pair = Path(__file__).parent / "data_dpa4_zbl_close_pair_pt2.lmp"
114+
104115
# Reference values, populated by ``_compute_expected`` in ``setup_module``.
105116
expected_e = None
106117
expected_ae = None
@@ -192,11 +203,13 @@ def setup_module() -> None:
192203
)
193204
_compute_expected()
194205
write_lmp_data(box, coord, type_NiO, data_file)
206+
write_lmp_data(box, coord_close_pair, type_NiO, data_file_close_pair)
195207

196208

197209
def teardown_module() -> None:
198-
if data_file.exists():
199-
os.remove(data_file)
210+
for f in (data_file, data_file_close_pair):
211+
if f.exists():
212+
os.remove(f)
200213

201214

202215
def _lammps(data_file, units="metal") -> PyLammps:
@@ -295,17 +308,24 @@ def test_pair_deepmd_atom_energy_and_virial(lammps) -> None:
295308

296309

297310
# ---------------------------------------------------------------------------
298-
# Multi-rank: NOT a correctness test -- a fail-fast test.
311+
# Multi-rank: 2-rank vs 1-rank close-pair parity (issue #5906 Task 2 E2E).
299312
# ---------------------------------------------------------------------------
300313

301314

302-
def _run_mpi_subprocess(nprocs: int, processors: str, timeout: float) -> dict:
315+
def _run_mpi_subprocess(
316+
nprocs: int,
317+
processors: str,
318+
timeout: float = _MPI_DEFAULT_TIMEOUT,
319+
data_path: Path | None = None,
320+
) -> dict:
303321
"""Run the (backend-agnostic) DPA3 MPI runner against the bridged archive
304-
and return ``{"returncode", "stdout", "stderr", "timed_out"}``.
322+
and return the parsed ``{"pe", "forces", "virials"}`` output.
305323
306324
Always bounded: on expiry the WHOLE mpirun process group is SIGKILLed
307325
(killing only mpirun can leave orphaned ranks blocking in a collective).
308326
"""
327+
if data_path is None:
328+
data_path = data_file
309329
with tempfile.NamedTemporaryFile(mode="r", suffix=".out", delete=False) as f:
310330
out_path = f.name
311331
try:
@@ -315,7 +335,7 @@ def _run_mpi_subprocess(nprocs: int, processors: str, timeout: float) -> dict:
315335
str(nprocs),
316336
sys.executable,
317337
str(mpi_runner),
318-
str(data_file.resolve()),
338+
str(data_path.resolve()),
319339
str(pb_file.resolve()),
320340
out_path,
321341
"--processors",
@@ -329,18 +349,25 @@ def _run_mpi_subprocess(nprocs: int, processors: str, timeout: float) -> dict:
329349
except sp.TimeoutExpired:
330350
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
331351
stdout, stderr = proc.communicate()
332-
return {
333-
"returncode": None,
334-
"stdout": stdout or "",
335-
"stderr": stderr or "",
336-
"timed_out": True,
337-
}
338-
return {
339-
"returncode": proc.returncode,
340-
"stdout": stdout,
341-
"stderr": stderr,
342-
"timed_out": False,
343-
}
352+
raise RuntimeError(
353+
f"mpirun timed out after {timeout}s (process group killed); "
354+
"a should-succeed MPI regression is deadlocked.\n"
355+
f"stdout:\n{(stdout or '')[-2000:]}\n"
356+
f"stderr:\n{(stderr or '')[-2000:]}"
357+
) from None
358+
if proc.returncode != 0:
359+
raise RuntimeError(
360+
f"mpirun exited {proc.returncode}.\n"
361+
f"stdout:\n{stdout[-2000:]}\nstderr:\n{stderr[-2000:]}"
362+
)
363+
with open(out_path) as fh:
364+
lines = fh.read().strip().splitlines()
365+
pe = float(lines[0])
366+
rows = np.array(
367+
[list(map(float, line.split())) for line in lines[1:]],
368+
dtype=np.float64,
369+
)
370+
return {"pe": pe, "forces": rows[:, :3], "virials": rows[:, 3:]}
344371
finally:
345372
if os.path.exists(out_path):
346373
os.remove(out_path)
@@ -352,40 +379,33 @@ def _run_mpi_subprocess(nprocs: int, processors: str, timeout: float) -> dict:
352379
@pytest.mark.skipif(
353380
importlib.util.find_spec("mpi4py") is None, reason="mpi4py is not installed"
354381
)
355-
def test_pair_deepmd_mpi_dpa4_zbl_fails_fast() -> None:
356-
"""A multi-rank run of a BRIDGED archive must fail loudly, not answer.
357-
358-
The bridged model is single-rank only by construction (see the module
359-
docstring), so its freeze exports no with-comm artifact while still
360-
declaring ``has_message_passing``. ``DeepPotPTExpt::compute_inner``'s
361-
dispatch reads exactly that combination -- graph lower + ``nprocs > 1`` +
362-
message passing + no with-comm artifact -- and throws before building any
363-
tensors. Without the guard the run would fall through to the plain
364-
single-rank artifact on a per-rank subdomain, where the bridging gate's
365-
per-node eta is incomplete: wrong, finite, plausible numbers.
366-
367-
The failure is uniform across ranks (every rank evaluates the same
368-
metadata-only predicate before any collective), so a TIMEOUT is a failure
369-
of this test: it would mean the guard regressed into a deadlock.
370-
371-
This is deliberately the ONLY multi-rank test in this file; there is no
372-
correct multi-rank reference for a bridged model to compare against.
382+
def test_pair_deepmd_mpi_dpa4_zbl_close_pair_parity() -> None:
383+
"""Issue #5906 Task 2 E2E: a bridged model, 2 ranks, with a 0.9 A
384+
contact STRADDLING the ``processors 2 1 1`` x-boundary.
385+
386+
Without the cross-boundary close pair this test is vacuous (edges at
387+
``r >= r_outer`` contribute ``log w = 0``); the geometry guard below
388+
pins that the pair actually straddles the split. The 2-rank run
389+
exercises the with-comm artifact's SFPG completion (reverse-accumulate
390+
+ broadcast of the per-node ``[log_eta, zero_count]`` partials), the
391+
per-block ghost exchange, and the reverse-comm force fold; the 1-rank
392+
run is the plain-artifact reference on the same trajectory.
373393
"""
374-
out = _run_mpi_subprocess(
375-
nprocs=2, processors="2 1 1", timeout=_MPI_DEFAULT_TIMEOUT
376-
)
377-
assert not out["timed_out"], (
378-
"Multi-rank run of the bridged archive timed out instead of failing "
379-
"promptly; the dispatch guard must throw on every rank BEFORE any "
380-
"collective."
394+
lx = float(box[1] - box[0])
395+
x_lo, x_hi = float(coord_close_pair[0, 0]), float(coord_close_pair[1, 0])
396+
assert x_lo < lx / 2.0 < x_hi, (
397+
"the close pair no longer straddles the processors 2 1 1 boundary; "
398+
"the parity below would be vacuous for the SFPG exchange"
381399
)
382-
assert out["returncode"] != 0, (
383-
"Expected the multi-rank run of a bridged (no with-comm artifact) "
384-
"archive to fail loudly, but it exited 0.\n"
385-
f"stdout:\n{out['stdout'][-2000:]}\nstderr:\n{out['stderr'][-2000:]}"
400+
ref = _run_mpi_subprocess(
401+
nprocs=1, processors="1 1 1", data_path=data_file_close_pair
386402
)
387-
combined = out["stdout"] + out["stderr"]
388-
assert "with-comm artifact" in combined, (
389-
"Expected the documented fail-loud message (mentioning the missing "
390-
f"'with-comm artifact'), got:\n{combined[-2000:]}"
403+
par = _run_mpi_subprocess(
404+
nprocs=2, processors="2 1 1", data_path=data_file_close_pair
391405
)
406+
assert par["pe"] == pytest.approx(ref["pe"], rel=1e-8, abs=1e-10)
407+
np.testing.assert_allclose(par["forces"], ref["forces"], atol=1e-8, rtol=0)
408+
# Same tolerance rationale as test_lammps_dpa4_graph_pt2.py's twin: the
409+
# relative component absorbs CUDA atomic-scatter ordering noise without
410+
# loosening the CPU-exact case.
411+
np.testing.assert_allclose(par["virials"], ref["virials"], atol=1e-8, rtol=1e-8)

0 commit comments

Comments
 (0)