1212handling, per-atom virial accumulation and unit conversion), so a regression
1313confined to it was invisible.
1414
15- Single -rank only, deliberately
16- ------------------------------
15+ Multi -rank correctness (issue #5906)
16+ ------------------------------------
1717Bridging enables the descriptor's Source Freeze Propagation Gate, whose
1818per-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
2829Reference values are computed LIVE at test-setup time via
2930``deepmd.infer.DeepPot.eval`` on the archive itself, mirroring
7273data_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.
7677mpi_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
101102# -> LAMMPS types [1,1,1,2,2,2] under identity ``pair_coeff * *``.
102103type_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``.
105116expected_e = None
106117expected_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
197209def 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
202215def _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 :]} \n stderr:\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 :]} \n stderr:\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