Skip to content

Commit 2362236

Browse files
wanghan-iapcmHan Wang
andauthored
feat(pt_expt): pluggable NeighborList strategy + O(N) vesin neighbor list for Python/ASE inference (deepmodeling#5491)
## Motivation When deepmd consumes a neighbor list, `forward_common`/`call_common` extends the local region into ~26 periodic-image buffer regions — `extend_coord_with_ghosts` then a dense `build_neighbor_list` — materializing ≈27×N ghost atoms and an O(N²) `[N, 27N]` distance matrix. This is the Python/ASE front-end bottleneck at large N (DPA4 manuscript §2.4). ## What this PR does Makes neighbor-list construction **pluggable** via an optional `NeighborList` strategy injected at `forward_common`/`call_common` (the layer where the system is extended). The exported `forward_common_lower` (the `.pt2`/AOTI/C++ entry) is left untouched, so there is **zero export risk**. - **dpmodel (torch-free core)** — `NeighborList` base + `DefaultNeighborList` (the historical dense extend+build). `neighbor_list=None` reproduces today's behavior **byte-identically**. - **pt_expt** — `VesinNeighborList`, a device-aware [`vesin.torch`](https://github.com/Luthaf/vesin) O(N) cell list: it runs on the input tensor's device (CPU or CUDA) for torch, and is CPU-bridged for numpy/dpmodel. It builds an `(i, j, S)` edge list, materializes only the real-neighbor ghosts `coord[j] + S@box`, and emits the same extended quartet `(extended_coord, extended_atype, nlist, mapping)`. Because the representation is identical, force / global-virial / **atomic-virial** all come out of the existing autograd + `communicate_extended_output` routines unchanged. - **inference** — `nlist_backend="auto" | "vesin" | "native"` on the pt_expt `DeepEval` and the ASE `DP` calculator. `auto` uses vesin when available/applicable and silently falls back to native otherwise; `vesin` is strict (raises if unavailable, or for spin / ASE-`neighbor_list` conflicts); `native` forces the dense builder. - **pyproject** — depends on `vesin[torch]`. ## Verification native vs vesin agree to fp round-off (energy `0.0`, force/virial/atomic-virial ≤ ~1e-18, the only difference being ghost-enumeration order): - `source/tests/pt_expt/utils/test_neighbor_list.py` — builder equivalence (numpy + torch namespaces, PBC/non-PBC, input-device placement) and full **model** equivalence across 8 descriptor families (se_e2_a, se_r, se_e3, dpa1, se_atten_v2, dpa2, dpa3, hybrid) for dpmodel (energy/atomic-energy) and pt_expt (energy/force/virial/atomic-virial), plus the `neighbor_list=None` byte-identical fallback. - `source/tests/pt_expt/infer/test_deep_eval.py::TestDeepEvalNlistBackend` — `nlist_backend` dispatch validation and vesin-vs-native equality through the compiled `.pte`. ## Known limitations - **Python `forward_common` path only.** This is the path where deepmd builds the nlist (DeepPot / ASE). C/C++/LAMMPS enter at the exported `forward_lower` with an externally-supplied list — accepting `(i,j,S)` there is a planned follow-up. - **Energy model validated;** spin is gated off vesin; dipole/polar/dos/hessian/multi-task and fparam/aparam are not yet covered by vesin equivalence tests (the seam is model-agnostic and the dense default path is regression-tested). - **Still materializes O(surface) ghost coords** (the minimal extended array); a truly buffer-free / sparse-edge-list consumption is deferred. In dense descriptors the env-mat per-edge tensors dominate memory anyway. - **No real-GPU CI** (the device test runs on CPU); ghosts are not deduped (one per `S≠0` edge — correctness preserved via `mapping` summation). - Multi-frame vesin build and the neighbor-truncation (`sel`-exceeded) path are not yet directly tested. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Pluggable neighbor-list backend for calculators and evaluators: "auto" (default), "native", or "vesin"; preserves historical all‑pairs behavior when None. * Adds a Vesin-based O(N) neighbor-list option for faster neighbor construction and an explicit Default (all‑pairs) builder. * **Tests** * Comprehensive test suites validating backend selection, error handling, device/shape robustness, and numeric equivalence (native vs vesin), including multi-frame cases. * **Chores** * Adds vesin[torch] runtime entry for the new backend. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Han Wang <wang_han@iapcm.ac.cn>
1 parent b77223e commit 2362236

14 files changed

Lines changed: 1381 additions & 34 deletions

File tree

deepmd/calculator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,15 @@ def __init__(
9191
type_dict: dict[str, int] | None = None,
9292
neighbor_list: Optional["NeighborList"] = None,
9393
head: str | None = None,
94+
nlist_backend: str = "auto",
9495
**kwargs: Any,
9596
) -> None:
9697
Calculator.__init__(self, label=label, **kwargs)
9798
self.dp = DeepPot(
9899
str(Path(model).resolve()),
99100
neighbor_list=neighbor_list,
100101
head=head,
102+
nlist_backend=nlist_backend,
101103
)
102104
if type_dict:
103105
self.type_dict = type_dict

deepmd/dpmodel/model/ener_model.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
from deepmd.dpmodel.output_def import (
2222
FittingOutputDef,
2323
)
24+
from deepmd.dpmodel.utils.neighbor_list import (
25+
NeighborList,
26+
)
2427

2528
from .dp_model import (
2629
DPModelCommon,
@@ -88,7 +91,23 @@ def call(
8891
aparam: Array | None = None,
8992
do_atomic_virial: bool = False,
9093
charge_spin: Array | None = None,
94+
neighbor_list: NeighborList | None = None,
9195
) -> dict[str, Array]:
96+
"""Evaluate the energy model.
97+
98+
Most arguments share the meaning of :meth:`call_common`.
99+
100+
Parameters
101+
----------
102+
neighbor_list
103+
The neighbor-list construction strategy forwarded to
104+
:meth:`call_common`. ``None`` uses the default all-pairs builder
105+
(:class:`~deepmd.dpmodel.utils.neighbor_list.NeighborList`
106+
subclass :class:`~deepmd.dpmodel.utils.default_neighbor_list.DefaultNeighborList`),
107+
reproducing the historical behavior; an alternative strategy may be
108+
injected to accelerate neighbor-list construction without changing
109+
the model outputs.
110+
"""
92111
model_ret = self.call_common(
93112
coord,
94113
atype,
@@ -97,6 +116,7 @@ def call(
97116
aparam=aparam,
98117
charge_spin=charge_spin,
99118
do_atomic_virial=do_atomic_virial,
119+
neighbor_list=neighbor_list,
100120
)
101121
model_predict = {}
102122
model_predict["atom_energy"] = model_ret["energy"]

deepmd/dpmodel/model/make_model.py

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,9 @@
3838
check_operation_applied,
3939
)
4040
from deepmd.dpmodel.utils import (
41-
build_neighbor_list,
42-
extend_coord_with_ghosts,
41+
DefaultNeighborList,
42+
NeighborList,
4343
nlist_distinguish_types,
44-
normalize_coord,
4544
)
4645
from deepmd.utils.path import (
4746
DPPath,
@@ -78,6 +77,7 @@ def model_call_from_call_lower(
7877
do_atomic_virial: bool = False,
7978
coord_corr_for_virial: Array | None = None,
8079
charge_spin: Array | None = None,
80+
neighbor_list: NeighborList | None = None,
8181
) -> dict[str, Array]:
8282
"""Return model prediction from lower interface.
8383
@@ -96,6 +96,12 @@ def model_call_from_call_lower(
9696
atomic parameter. nf x nloc x nda
9797
do_atomic_virial
9898
If calculate the atomic virial.
99+
neighbor_list
100+
The neighbor-list construction strategy. ``None`` uses the default
101+
all-pairs builder (:class:`DefaultNeighborList`), reproducing the
102+
historical behavior. An alternative strategy (e.g. an O(N) cell list)
103+
may be injected to speed up neighbor-list construction; it returns the
104+
same extended representation, so model outputs are unchanged.
99105
100106
Returns
101107
-------
@@ -107,26 +113,9 @@ def model_call_from_call_lower(
107113
nframes, nloc = atype.shape[:2]
108114
cc, bb, fp, ap = coord, box, fparam, aparam
109115
del coord, box, fparam, aparam
110-
if bb is not None:
111-
coord_normalized = normalize_coord(
112-
cc.reshape(nframes, nloc, 3),
113-
bb.reshape(nframes, 3, 3),
114-
)
115-
else:
116-
xp = array_api_compat.array_namespace(cc)
117-
coord_normalized = xp.reshape(cc, (nframes, nloc, 3))
118-
extended_coord, extended_atype, mapping = extend_coord_with_ghosts(
119-
coord_normalized, atype, bb, rcut
120-
)
121-
nlist = build_neighbor_list(
122-
extended_coord,
123-
extended_atype,
124-
nloc,
125-
rcut,
126-
sel,
127-
# types will be distinguished in the lower interface,
128-
# so it doesn't need to be distinguished here
129-
distinguish_types=False,
116+
builder = neighbor_list if neighbor_list is not None else DefaultNeighborList()
117+
extended_coord, extended_atype, nlist, mapping = builder.build(
118+
cc, atype, bb, rcut, sel
130119
)
131120
extended_coord = extended_coord.reshape(nframes, -1, 3)
132121
if coord_corr_for_virial is not None:
@@ -269,6 +258,7 @@ def call_common(
269258
do_atomic_virial: bool = False,
270259
coord_corr_for_virial: Array | None = None,
271260
charge_spin: Array | None = None,
261+
neighbor_list: NeighborList | None = None,
272262
) -> dict[str, Array]:
273263
"""Return model prediction.
274264
@@ -290,6 +280,11 @@ def call_common(
290280
coord_corr_for_virial
291281
The coordinates correction for virial.
292282
shape: nf x (nloc x 3)
283+
neighbor_list
284+
The neighbor-list construction strategy. ``None`` uses the
285+
default all-pairs builder; an alternative strategy (e.g. an O(N)
286+
cell list) may be injected to speed up neighbor-list construction
287+
without changing model outputs.
293288
294289
Returns
295290
-------
@@ -316,6 +311,7 @@ def call_common(
316311
do_atomic_virial=do_atomic_virial,
317312
coord_corr_for_virial=coord_corr_for_virial,
318313
charge_spin=cs,
314+
neighbor_list=neighbor_list,
319315
)
320316
model_predict = self._output_type_cast(model_predict, input_prec)
321317
return model_predict

deepmd/dpmodel/utils/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
from .default_neighbor_list import (
3+
DefaultNeighborList,
4+
)
25
from .env_mat import (
36
EnvMat,
47
)
@@ -15,6 +18,9 @@
1518
is_lmdb,
1619
make_neighbor_stat_data,
1720
)
21+
from .neighbor_list import (
22+
NeighborList,
23+
)
1824
from .network import (
1925
EmbeddingNet,
2026
FittingNet,
@@ -53,6 +59,7 @@
5359

5460
__all__ = [
5561
"AtomExcludeMask",
62+
"DefaultNeighborList",
5663
"DistributedSameNlocBatchSampler",
5764
"EmbeddingNet",
5865
"EnvMat",
@@ -62,6 +69,7 @@
6269
"LmdbTestDataNlocView",
6370
"NativeLayer",
6471
"NativeNet",
72+
"NeighborList",
6573
"NetworkCollection",
6674
"PairExcludeMask",
6775
"SameNlocBatchSampler",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Default all-pairs neighbor-list builder (historical deepmd behavior)."""
3+
4+
import array_api_compat
5+
6+
from deepmd.dpmodel.array_api import (
7+
Array,
8+
)
9+
10+
from .neighbor_list import (
11+
NeighborList,
12+
)
13+
from .nlist import (
14+
build_neighbor_list,
15+
extend_coord_with_ghosts,
16+
)
17+
from .region import (
18+
normalize_coord,
19+
)
20+
21+
22+
class DefaultNeighborList(NeighborList):
23+
"""All-pairs builder: replicate the cell into periodic images and rank by
24+
distance (:func:`~deepmd.dpmodel.utils.nlist.extend_coord_with_ghosts` +
25+
:func:`~deepmd.dpmodel.utils.nlist.build_neighbor_list`). This is the
26+
default when no strategy is supplied, so results are unchanged.
27+
"""
28+
29+
def build(
30+
self,
31+
coord: Array,
32+
atype: Array,
33+
box: Array | None,
34+
rcut: float,
35+
sel: list[int],
36+
) -> tuple[Array, Array, Array, Array]:
37+
xp = array_api_compat.array_namespace(coord, atype)
38+
nframes, nloc = atype.shape[:2]
39+
if box is not None:
40+
coord_normalized = normalize_coord(
41+
xp.reshape(coord, (nframes, nloc, 3)),
42+
xp.reshape(box, (nframes, 3, 3)),
43+
)
44+
else:
45+
coord_normalized = xp.reshape(coord, (nframes, nloc, 3))
46+
extended_coord, extended_atype, mapping = extend_coord_with_ghosts(
47+
coord_normalized, atype, box, rcut
48+
)
49+
# types are distinguished in the lower interface, so keep them merged here
50+
nlist = build_neighbor_list(
51+
extended_coord,
52+
extended_atype,
53+
nloc,
54+
rcut,
55+
sel,
56+
distinguish_types=False,
57+
)
58+
extended_coord = xp.reshape(extended_coord, (nframes, -1, 3))
59+
return extended_coord, extended_atype, nlist, mapping
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Pluggable neighbor-list construction strategies.
3+
4+
A :class:`NeighborList` turns local coordinates (and an optional cell) into the
5+
*extended* representation consumed by the model's lower interface. The default
6+
all-pairs builder lives in :mod:`deepmd.dpmodel.utils.default_neighbor_list`;
7+
backend-specific O(N) builders (e.g. the ``vesin``-based one in
8+
``deepmd.pt_expt.utils.vesin_neighbor_list``) subclass :class:`NeighborList`
9+
and are injected into the model, so the rest of the model is agnostic to how the
10+
neighbor list was built.
11+
"""
12+
13+
from deepmd.dpmodel.array_api import (
14+
Array,
15+
)
16+
17+
18+
class NeighborList:
19+
"""Strategy that builds the extended neighbor environment from local atoms.
20+
21+
Implementations turn local coordinates into the extended representation: the
22+
coordinates and atom types of local-plus-ghost (periodic-image) atoms, a
23+
candidate neighbor list indexing the extended atoms, and a mapping from each
24+
extended atom to its local owner. Implementations are stateless --
25+
``rcut``/``sel`` are supplied by the model at call time.
26+
"""
27+
28+
def build(
29+
self,
30+
coord: Array,
31+
atype: Array,
32+
box: Array | None,
33+
rcut: float,
34+
sel: list[int],
35+
) -> tuple[Array, Array, Array, Array]:
36+
"""Build the extended system and a candidate neighbor list.
37+
38+
Parameters
39+
----------
40+
coord
41+
local coordinates, shape (nf, nloc, 3) or (nf, nloc*3).
42+
atype
43+
local atom types, shape (nf, nloc).
44+
box
45+
simulation cell, shape (nf, 3, 3) or (nf, 9); ``None`` for non-periodic.
46+
rcut
47+
cutoff radius.
48+
sel
49+
number of selected neighbors per type.
50+
51+
Returns
52+
-------
53+
extended_coord
54+
shape (nf, nall, 3).
55+
extended_atype
56+
shape (nf, nall).
57+
nlist
58+
shape (nf, nloc, nnei), type-undistinguished candidate neighbors
59+
indexing the extended atoms (the lower interface re-formats it:
60+
distance sort, truncate to ``sel``, split by type).
61+
mapping
62+
shape (nf, nall), mapping each extended atom to its local owner.
63+
"""
64+
raise NotImplementedError

0 commit comments

Comments
 (0)