Skip to content

Commit 34d1fdb

Browse files
wanghan-iapcmHan Wang
andauthored
fix(dpmodel): dpa1 dense call must not route through divergent graph adapter (#5785)
## Problem `DescrptDPA1.call` routes graph-eligible configs through `_call_graph_adapter` (decision #14: dense call = thin adapter). That adapter is bit-exact vs `_call_dense` **only in the trivial-statistics regime** (`davg == 0`): the dense se_atten body leaks a phantom padding-neighbor `-davg/dstd` residual (`EnvMat.call` subtracts `davg` after the padding rows' geometry is weight-zeroed; with empty `exclude_types` nothing re-masks it, at **any** `attn_layer`) that the graph path deliberately omits. Measured on the consistency fixture: dense vs adapter agree at 0.0 for `davg=0`, diverge by ~20 (abs) for nonzero `davg`, at both `attn_layer=0` and `2`. The gate also made `mapping` — an argument that only enables ghost folding on graph routes — silently change the numerics of a dense call: `call(..., mapping)` and `call(...)` returned different descriptors for the same physical system. ## Why it never surfaced The pt/pd consistency tests invoke `dd2.call()` **without** `mapping`, so the routing gate (`mapping is not None or nall == nloc`) is off and the tests exercise the dense body — a code path production never takes: `DPAtomicModel.forward_atomic` always forwards `mapping`. Consequence: dpmodel/jax/tf2 inference of a **trained** dpa1 with `set_davg_zero=False` (the constructor default) silently used the adapter and differed from the pt/tf dense backends. Cross-backend suites stayed green only because fresh models sit in the trivial-stat regime. This is the dpa1 twin of the dpa2 routing fix in #5779 (wanghan-iapcm/deepmd-kit@bc0d70ade, where the same class of divergence was caught by CI because the dpa2 consistency test does pass `mapping`). ## Fix - `DescrptDPA1.call` always runs `_call_dense` — it is the cross-backend consistency reference. The graph-native route is reached exclusively through `call_graph` (pt_expt `forward_atomic_graph` and the graph `.pt2`); nothing in shipping code consumed the `call` flip. - `_call_graph_adapter` is retained as the bit-exact-regime reference; its docstring now states the actual regime (including the slot-0 statistics simplification, exact for real slot-uniform stat tables). ## Tests - pt/pd `test_dpa1.py::test_consistency` now pass `mapping` (the production invocation). Red on the old routing (71%-scale mismatch), green with the fix. - New regression: `call(mapping) == call(None) == _call_dense` at zero tolerance with nonzero injected `davg` and ghosts present. - Adapter parity tests (`test_dpa1_call_graph_descriptor.py`, `test_dpa1_graph_attention_parity.py`) exercise `_call_graph_adapter` directly instead of relying on the removed `call` routing — adapter coverage is unchanged. - Local sweep: dpa1/se_atten_v2 graph+consistency+universal battery — 94 + 304 + 852 passed. ## Notes - No conflict expected with #5779 (different files); the two PRs are independent. - pt_expt training/export graph routes are untouched (`uses_graph_lower` still gates them; it no longer influences the dense `call`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Ensured descriptor calculations consistently use the dense path, regardless of mapping or graph-related conditions. * Preserved graph-based processing through its dedicated pathway. * Prevented mapping settings from changing dense numerical results. * **Tests** * Expanded parity and consistency coverage across dense and graph-based calculations. * Added regression checks for mapping, ghost atoms, exclusions, and single-rank scenarios. * Improved validation of bit-exact results and production-path behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Han Wang <wang_han@iapcm.ac.cn>
1 parent d798a8a commit 34d1fdb

5 files changed

Lines changed: 121 additions & 47 deletions

File tree

deepmd/dpmodel/descriptor/dpa1.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -596,18 +596,25 @@ def call(
596596
sw
597597
The smooth switch function.
598598
"""
599-
xp = array_api_compat.array_namespace(coord_ext, atype_ext, nlist)
600-
nloc = nlist.shape[1]
601-
nall = xp.reshape(coord_ext, (nlist.shape[0], -1)).shape[1] // 3
602-
# graph-eligible configs route through the graph-native adapter (decision
603-
# #14: graph = single math source, dense call = thin adapter). Ineligible
604-
# configs (compressed descriptors) and the ghost case with no mapping
605-
# fall back to the legacy dense body. The graph needs `mapping` to fold
606-
# ghosts to local owners; without it only nall == nloc is valid.
607-
if self.uses_graph_lower() and (mapping is not None or nall == nloc):
608-
return self._call_graph_adapter(coord_ext, atype_ext, nlist, mapping)
609-
else:
610-
return self._call_dense(coord_ext, atype_ext, nlist)
599+
# The dense ``call`` always runs the legacy dense body -- it is the
600+
# cross-backend consistency reference and must match the tf/pt/pd/jax
601+
# dense descriptors bit-for-bit. It previously routed graph-eligible
602+
# configs through ``_call_graph_adapter`` (decision #14), but the
603+
# adapter is bit-exact ONLY in the trivial-statistics regime
604+
# (``davg == 0``): the dense se_atten body leaks a phantom
605+
# padding-neighbor ``-davg/dstd`` residual (``EnvMat.call`` subtracts
606+
# ``davg`` AFTER the padding rows' geometry is weight-zeroed, and
607+
# neither the empty ``exclude_types`` mask nor the attention layers
608+
# re-mask it) that the graph path deliberately omits -- the graph
609+
# output is the physically correct one, but ``call`` must reproduce
610+
# the dense reference. The former gate also made ``mapping`` -- an
611+
# argument that only enables ghost folding on graph routes -- silently
612+
# change the numerics of a dense call. The graph-native route is
613+
# reached exclusively through :meth:`call_graph` (pt_expt
614+
# ``forward_atomic_graph`` and the graph ``.pt2``), never through
615+
# ``call``. ``_call_graph_adapter`` is retained as the
616+
# bit-exact-regime reference exercised by the adapter parity tests.
617+
return self._call_dense(coord_ext, atype_ext, nlist)
611618

612619
def _call_graph_adapter(
613620
self,
@@ -616,14 +623,27 @@ def _call_graph_adapter(
616623
nlist: Array,
617624
mapping: Array | None,
618625
) -> Array:
619-
"""Regime-1 dense->graph adapter (the eligible ``call`` path).
626+
"""Regime-1 dense->graph adapter.
620627
621628
Builds a NeighborGraph from the dense quartet with the SHAPE-STATIC
622629
converter (``compact=False``, so this is jit/export-traceable -- no
623630
``nonzero``), runs :meth:`call_graph`, and reconstructs the dense-shaped
624-
``sw``. Preserves the dense 5-tuple ABI exactly; masked invalid edges
625-
contribute zero in ``call_graph``'s ``segment_sum`` so the output is
626-
identical to the legacy dense body.
631+
``sw``. Preserves the dense 5-tuple ABI; masked invalid edges
632+
contribute zero in ``call_graph``'s ``segment_sum``.
633+
634+
Bit-exact vs :meth:`_call_dense` **only in the trivial-statistics
635+
regime** (``davg == 0``). For nonzero ``davg`` the dense body leaks a
636+
phantom padding-neighbor ``-davg/dstd`` residual into every padding
637+
slot (``EnvMat.call`` subtracts ``davg`` AFTER the padding geometry is
638+
weight-zeroed; with empty ``exclude_types`` nothing re-masks it, at
639+
any ``attn_layer``) that the graph path deliberately omits. The graph
640+
kernel additionally applies the slot-0 statistics ``[:, 0, :]`` to
641+
every edge -- exact for real stat-computed tables (slot-uniform by
642+
construction), not for artificially slot-varying ones. This is why
643+
the dense :meth:`call` does NOT route here: it is the cross-backend
644+
consistency reference. This method is retained as the
645+
bit-exact-regime reference exercised by the adapter parity tests; the
646+
production graph route is :meth:`call_graph`.
627647
628648
Parameters
629649
----------

source/tests/common/dpmodel/test_dpa1_call_graph_descriptor.py

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2-
"""Full 5-tuple ABI parity between the graph-routed ``DescrptDPA1.call``
3-
(attn_layer=0, which now goes ``from_dense_quartet -> call_graph``) and the
4-
legacy dense descriptor output captured BEFORE the swap, for binding AND
5-
non-binding ``sel``.
2+
"""Full 5-tuple ABI parity between ``DescrptDPA1._call_graph_adapter``
3+
(``from_dense_quartet -> call_graph``) and the legacy dense descriptor
4+
output, for binding AND non-binding ``sel``.
65
7-
The dense reference is reconstructed by calling the BLOCK directly
8-
(``dd.se_atten.call``) and applying the descriptor-level ``concat_output_tebd``
9-
step by hand (mirroring dpa1.py), because ``dd.call`` itself now routes through
10-
the graph for ``attn_layer == 0``.
6+
The public ``dd.call`` does NOT route through the adapter (it is the
7+
cross-backend dense reference; the adapter is bit-exact only in the
8+
trivial-statistics regime these fresh models are in), so the adapter is
9+
exercised directly. The dense reference is reconstructed by calling the
10+
BLOCK directly (``dd.se_atten.call``) and applying the descriptor-level
11+
``concat_output_tebd`` step by hand (mirroring dpa1.py).
1112
"""
1213

1314
import numpy as np
@@ -66,7 +67,7 @@ def _dense_reference(self, dd, ext_coord, ext_atype, nlist):
6667

6768
@pytest.mark.parametrize("sel", [[30], [4]]) # non-binding AND binding
6869
def test_descriptor_graph_equals_dense_full_tuple(self, sel) -> None:
69-
"""Graph-routed dd.call() returns the identical dense 5-tuple ABI."""
70+
"""``_call_graph_adapter`` returns the identical dense 5-tuple ABI."""
7071
dd = self._make(sel)
7172
(
7273
ext_coord,
@@ -81,10 +82,9 @@ def test_descriptor_graph_equals_dense_full_tuple(self, sel) -> None:
8182
mixed_types=dd.mixed_types(),
8283
box=None,
8384
)
84-
# dense reference captured via the block (pre-swap behaviour)
85+
# dense reference captured via the block
8586
ref = self._dense_reference(dd, ext_coord, ext_atype, nlist)
86-
# the swapped public ABI: routes through the graph
87-
out = dd.call(ext_coord, ext_atype, nlist, mapping=mapping)
87+
out = dd._call_graph_adapter(ext_coord, ext_atype, nlist, mapping)
8888
assert len(out) == 5
8989
# grrg
9090
np.testing.assert_allclose(out[0], ref[0], rtol=1e-12, atol=1e-12)
@@ -138,8 +138,8 @@ def test_exclude_types_graph_eligible_and_parity(self, exclude_types) -> None:
138138
)
139139
# dense reference (calls block directly)
140140
ref = self._dense_reference(dd, ext_coord, ext_atype, nlist)
141-
# graph-routed public call
142-
out = dd.call(ext_coord, ext_atype, nlist, mapping=mapping)
141+
# graph adapter, exercised directly (the public ``call`` is dense)
142+
out = dd._call_graph_adapter(ext_coord, ext_atype, nlist, mapping)
143143
assert len(out) == 5
144144
np.testing.assert_allclose(out[0], ref[0], rtol=1e-12, atol=1e-12)
145145
np.testing.assert_allclose(out[1], ref[1], rtol=1e-12, atol=1e-12)
@@ -161,9 +161,10 @@ def test_exclude_types_graph_eligible_and_parity(self, exclude_types) -> None:
161161

162162
def test_eligible_no_mapping_with_ghosts_falls_back(self) -> None:
163163
"""An eligible (concat) attn_layer=0 descriptor called with mapping=None
164-
on a PERIODIC system (nall > nloc ghosts) must fall back to the dense
165-
body and match it because the graph path requires an explicit ghost
166-
mapping.
164+
on a PERIODIC system (nall > nloc ghosts) must run the dense body and
165+
match it (the public ``call`` is unconditionally dense; under the old
166+
routing gate this pinned the mapping-less fallback, since the graph
167+
path requires an explicit ghost mapping).
167168
"""
168169
dd = self._make([30])
169170
box = np.eye(3, dtype=np.float64)[None] * 6.0
@@ -180,6 +181,44 @@ def test_eligible_no_mapping_with_ghosts_falls_back(self) -> None:
180181
out = dd.call(ext_coord, ext_atype, nlist, mapping=None) # must not IndexError
181182
np.testing.assert_allclose(out[0], ref[0], rtol=1e-12, atol=1e-12)
182183

184+
def test_call_is_mapping_insensitive_with_nontrivial_stats(self) -> None:
185+
"""``call(..., mapping)`` == ``call(..., None)`` == ``_call_dense`` with
186+
NONZERO ``davg`` and ghosts present.
187+
188+
Regression for the removed graph-adapter routing gate: ``mapping`` only
189+
enables ghost folding on graph routes and must never change the dense
190+
numerics. Under the old gate, passing ``mapping`` silently switched an
191+
eligible ``call`` to ``_call_graph_adapter``, whose output differs from
192+
the dense reference whenever ``davg != 0`` (the dense phantom
193+
padding-slot ``-davg/dstd`` leak the graph path deliberately omits) --
194+
so the same physical system gave different descriptors depending on
195+
whether the caller supplied ``mapping``. Nonzero stats are injected
196+
precisely because the fresh-model default (``davg == 0``) is the one
197+
regime where the two routes coincide and the bug is invisible.
198+
"""
199+
rng = np.random.default_rng(7)
200+
dd = self._make([30])
201+
nnei = sum(dd.get_sel())
202+
dd.se_atten.mean = rng.normal(size=(2, nnei, 4))
203+
dd.se_atten.stddev = 0.1 + np.abs(rng.normal(size=(2, nnei, 4)))
204+
box = np.eye(3, dtype=np.float64)[None] * 6.0
205+
ext_coord, ext_atype, mapping, nlist = extend_input_and_build_neighbor_list(
206+
self.coord,
207+
self.atype,
208+
dd.get_rcut(),
209+
dd.get_sel(),
210+
mixed_types=dd.mixed_types(),
211+
box=box,
212+
)
213+
assert ext_atype.shape[1] > self.nloc # ghosts present
214+
with_mapping = dd.call(ext_coord, ext_atype, nlist, mapping=mapping)
215+
without_mapping = dd.call(ext_coord, ext_atype, nlist, mapping=None)
216+
dense = dd._call_dense(ext_coord, ext_atype, nlist)
217+
np.testing.assert_allclose(
218+
with_mapping[0], without_mapping[0], rtol=0.0, atol=0.0
219+
)
220+
np.testing.assert_allclose(with_mapping[0], dense[0], rtol=0.0, atol=0.0)
221+
183222
def test_single_rank_extension_keeps_type_invariant(self) -> None:
184223
"""The ghost-free graph types a neighbor as ``atype[mapping[neighbor]]``
185224
(its local owner). This is correct because a real single-rank extension
@@ -188,7 +227,7 @@ def test_single_rank_extension_keeps_type_invariant(self) -> None:
188227
a periodic image of its owner and shares its type. This test pins that
189228
invariant (an inconsistent ``mapping`` like the universal fixture's old
190229
buggy permutation is NOT a valid single-rank extension) and confirms the
191-
graph-routed ``call`` matches dense on the resulting quartet.
230+
graph adapter matches dense on the resulting quartet.
192231
"""
193232
dd = self._make([30])
194233
box = np.eye(3, dtype=np.float64)[None] * 6.0
@@ -208,7 +247,7 @@ def test_single_rank_extension_keeps_type_invariant(self) -> None:
208247
ext_atype[f], ext_atype[f][mapping[f]]
209248
) # atype_ext[k] == atype[mapping[k]]
210249
ref = self._dense_reference(dd, ext_coord, ext_atype, nlist)
211-
out = dd.call(ext_coord, ext_atype, nlist, mapping=mapping)
250+
out = dd._call_graph_adapter(ext_coord, ext_atype, nlist, mapping)
212251
np.testing.assert_allclose(out[0], ref[0], rtol=1e-12, atol=1e-12)
213252
np.testing.assert_allclose(out[1], ref[1], rtol=1e-12, atol=1e-12)
214253
np.testing.assert_allclose(out[4], ref[4], rtol=1e-12, atol=1e-12)
@@ -299,9 +338,12 @@ def test_uses_graph_lower_strip_gate(self) -> None:
299338
def test_call_strip_graph_equals_dense(
300339
self, type_one_side, smooth, attn_layer
301340
) -> None:
302-
"""The routed ``call`` (graph adapter) is bit-exact with ``_call_dense``."""
341+
"""The strip graph adapter is bit-exact with ``_call_dense`` for a
342+
fresh model (trivial statistics -- the adapter's bit-exact regime;
343+
the public ``call`` itself is always dense).
344+
"""
303345
dd = self._make(type_one_side, smooth, attn_layer)
304-
assert dd.uses_graph_lower() is True # precondition: call routes to graph
346+
assert dd.uses_graph_lower() is True # strip is graph-eligible
305347
(
306348
ext_coord,
307349
ext_atype,
@@ -315,10 +357,10 @@ def test_call_strip_graph_equals_dense(
315357
mixed_types=dd.mixed_types(),
316358
box=None,
317359
)
318-
routed = dd.call(ext_coord, ext_atype, nlist, mapping=mapping)
360+
adapter = dd._call_graph_adapter(ext_coord, ext_atype, nlist, mapping)
319361
dense = dd._call_dense(ext_coord, ext_atype, nlist)
320-
assert len(routed) == len(dense)
321-
for r, d in zip(routed, dense, strict=True):
362+
assert len(adapter) == len(dense)
363+
for r, d in zip(adapter, dense, strict=True):
322364
if r is None:
323365
assert d is None
324366
continue

source/tests/common/dpmodel/test_dpa1_graph_attention_parity.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,11 @@ def test_se_atten_v2_is_graph_eligible(self) -> None:
226226
assert dd.uses_graph_lower() is True
227227

228228
def test_se_atten_v2_graph_equals_dense(self) -> None:
229-
"""The graph-routed se_atten_v2 ``call`` is bit-exact with ``_call_dense``
229+
"""The se_atten_v2 graph adapter is bit-exact with ``_call_dense``
230230
(the ``static_nnei`` adapter reproduces the dense phantom terms despite
231-
smooth=True) at a non-binding sel.
231+
smooth=True) at a non-binding sel, for a fresh model (trivial
232+
statistics -- the adapter's bit-exact regime; the public ``call``
233+
itself is always dense).
232234
"""
233235
from deepmd.dpmodel.descriptor.se_atten_v2 import (
234236
DescrptSeAttenV2,
@@ -243,10 +245,10 @@ def test_se_atten_v2_graph_equals_dense(self) -> None:
243245
ext_coord, ext_atype, mapping, nlist = extend_input_and_build_neighbor_list(
244246
coord, atype, dd.get_rcut(), dd.get_sel(), mixed_types=True, box=None
245247
)
246-
routed = dd.call(ext_coord, ext_atype, nlist, mapping=mapping)
248+
adapter = dd._call_graph_adapter(ext_coord, ext_atype, nlist, mapping)
247249
dense = dd._call_dense(ext_coord, ext_atype, nlist)
248-
assert len(routed) == len(dense)
249-
for r, d in zip(routed, dense, strict=True):
250+
assert len(adapter) == len(dense)
251+
for r, d in zip(adapter, dense, strict=True):
250252
if r is None:
251253
assert d is None
252254
continue

source/tests/pd/model/test_dpa1.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,17 @@ def test_consistency(
9797
atol=atol,
9898
err_msg=err_msg,
9999
)
100-
# dp impl
100+
# dp impl. `mapping` is passed because that is the production
101+
# invocation (DPAtomicModel.forward_atomic always forwards it);
102+
# the dense `.call()` must give the same answer with or without
103+
# it -- mapping only enables ghost folding on graph routes, it
104+
# must never change the dense numerics.
101105
dd2 = DPDescrptDPA1.deserialize(dd0.serialize())
102106
rd2, _, _, _, _ = dd2.call(
103107
self.coord_ext,
104108
self.atype_ext,
105109
self.nlist,
110+
self.mapping,
106111
)
107112
np.testing.assert_allclose(
108113
rd0.detach().cpu().numpy(),

source/tests/pt/model/test_dpa1.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,17 @@ def test_consistency(
104104
atol=atol,
105105
err_msg=err_msg,
106106
)
107-
# dp impl
107+
# dp impl. `mapping` is passed because that is the production
108+
# invocation (DPAtomicModel.forward_atomic always forwards it);
109+
# the dense `.call()` must give the same answer with or without
110+
# it -- mapping only enables ghost folding on graph routes, it
111+
# must never change the dense numerics.
108112
dd2 = DPDescrptDPA1.deserialize(dd0.serialize())
109113
rd2, _, _, _, _ = dd2.call(
110114
self.coord_ext,
111115
self.atype_ext,
112116
self.nlist,
117+
self.mapping,
113118
)
114119
np.testing.assert_allclose(
115120
rd0.detach().cpu().numpy(),

0 commit comments

Comments
 (0)