Skip to content

Commit c612f05

Browse files
committed
feat(pt_expt): auto-select O(N) NeighborGraph builder by default
Replace the hard-coded None→dense default with a shared availability- probing ladder (CUDA: nv→vesin→dense; CPU: vesin→dense) used by the model default-flip, DeepEval, and compiled training's eager builder. Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>
1 parent 8f2a3f1 commit c612f05

9 files changed

Lines changed: 305 additions & 51 deletions

File tree

deepmd/pt_expt/infer/deep_eval.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,18 @@ class DeepEval(DeepEvalBackend):
139139
Neighbor-list builder for the NLIST/extended lower path (``.pte`` and
140140
nlist-form ``.pt2``): ``"auto"`` / ``"vesin"`` / ``"native"``. Not
141141
used by graph-form ``.pt2`` artifacts.
142-
neighbor_graph_method : str, default: "dense"
142+
neighbor_graph_method : str, default: "auto"
143143
Carry-all graph builder for GRAPH-FORM ``.pt2`` artifacts ONLY
144-
(``metadata["lower_input_kind"] == "graph"``): ``"dense"`` / ``"ase"``
145-
(backend-agnostic) or ``"vesin"`` / ``"nv"`` (on-device O(N)). A
146-
non-default value on any other artifact raises at construction — the
147-
knob would silently do nothing there; use ``nlist_backend`` for the
148-
nlist path instead. All builders emit the same neighbor set, so the
149-
choice is performance-only. Consolidating the two knobs into a single
150-
backend-selection API is deferred to the dense-nlist deprecation.
144+
(``metadata["lower_input_kind"] == "graph"``): ``"auto"`` (availability-
145+
probed O(N) default; see
146+
:func:`~deepmd.pt_expt.utils.neighbor_graph_method.resolve_auto_graph_builder`),
147+
``"dense"`` / ``"ase"`` (backend-agnostic), or ``"vesin"`` / ``"nv"``
148+
(on-device O(N)). A non-default explicit value on any other artifact
149+
raises at construction — the knob would silently do nothing there; use
150+
``nlist_backend`` for the nlist path instead. All builders emit the
151+
same neighbor set, so the choice is performance-only. Consolidating
152+
the two knobs into a single backend-selection API is deferred to the
153+
dense-nlist deprecation.
151154
**kwargs : dict
152155
Keyword arguments.
153156
"""
@@ -160,14 +163,14 @@ def __init__(
160163
auto_batch_size: bool | int | AutoBatchSize = True,
161164
neighbor_list: Optional["ase.neighborlist.NewPrimitiveNeighborList"] = None,
162165
nlist_backend: str = "auto",
163-
neighbor_graph_method: str = "dense",
166+
neighbor_graph_method: str = "auto",
164167
**kwargs: Any,
165168
) -> None:
166169
self.output_def = output_def
167170
self.model_path = model_file
168171
self.neighbor_list = neighbor_list
169172
# World-2 graph-form ``.pt2`` (lower_input_kind == "graph") builder select:
170-
# "dense"/"ase" (backend-agnostic) or "vesin"/"nv" (on-device O(N)).
173+
# "auto" (probed) / "dense"/"ase" (backend-agnostic) / "vesin"/"nv" (O(N)).
171174
self._neighbor_graph_method = neighbor_graph_method
172175
self._is_pt2 = model_file.endswith(".pt2")
173176

@@ -185,11 +188,13 @@ def __init__(
185188
)
186189

187190
# neighbor_graph_method is consumed ONLY by graph-form .pt2 eval
188-
# (_eval_model_graph); fail fast instead of silently ignoring it on
189-
# nlist-form artifacts (there, the builder knob is nlist_backend).
190-
if neighbor_graph_method != "dense" and getattr(self, "metadata", {}).get(
191-
"lower_input_kind"
192-
) not in ("graph", "dpa1_canonical"):
191+
# (_eval_model_graph); fail fast instead of silently ignoring an
192+
# EXPLICIT builder on nlist-form artifacts (there, the builder knob
193+
# is nlist_backend). "auto" / "dense" are allowed as no-ops so the
194+
# default constructor works for every artifact kind.
195+
if neighbor_graph_method not in ("auto", "dense") and getattr(
196+
self, "metadata", {}
197+
).get("lower_input_kind") not in ("graph", "dpa1_canonical"):
193198
raise ValueError(
194199
f"neighbor_graph_method={neighbor_graph_method!r} only applies to "
195200
"graph-form .pt2 artifacts (lower_input_kind == 'graph'); this "
@@ -1930,14 +1935,21 @@ def _build_eval_graph(
19301935
) -> "NeighborGraph":
19311936
"""Build the carry-all NeighborGraph for graph-form ``.pt2`` inference.
19321937
1933-
Dispatches on ``self._neighbor_graph_method``: ``dense``/``ase`` run
1934-
backend-agnostic (numpy); ``vesin``/``nv`` run on-device (torch, O(N)).
1935-
All backends emit the SAME neighbor set (carry-all, sel-free), so the
1936-
selection is a pure performance choice and results are unchanged. The
1937-
result is canonicalized to the destination-major graph-form ``.pt2``
1938-
ABI after construction.
1938+
Dispatches on ``self._neighbor_graph_method``: ``auto`` resolves via
1939+
:func:`~deepmd.pt_expt.utils.neighbor_graph_method.resolve_auto_graph_builder`;
1940+
``dense``/``ase`` run backend-agnostic (numpy); ``vesin``/``nv`` run
1941+
on-device (torch, O(N)). All backends emit the SAME neighbor set
1942+
(carry-all, sel-free), so the selection is a pure performance choice
1943+
and results are unchanged. The result is canonicalized to the
1944+
destination-major graph-form ``.pt2`` ABI after construction.
19391945
"""
1946+
from deepmd.pt_expt.utils.neighbor_graph_method import (
1947+
resolve_auto_graph_builder,
1948+
)
1949+
19401950
method = self._neighbor_graph_method
1951+
if method == "auto":
1952+
method = resolve_auto_graph_builder(device)
19411953
# Model-level ``pair_exclude_types`` is a graph-BUILD transform
19421954
# (decision #18): apply it here so the exported ``.pt2`` lower consumes a
19431955
# pre-excluded ``edge_mask`` and never re-applies it (mirrors the C++
@@ -2006,7 +2018,7 @@ def _build_eval_graph(
20062018
)
20072019
raise ValueError(
20082020
f"unknown neighbor_graph_method {method!r}; "
2009-
"use 'dense', 'ase', 'vesin', or 'nv'"
2021+
"use 'auto', 'dense', 'ase', 'vesin', or 'nv'"
20102022
)
20112023

20122024
def _model_pair_excl(self) -> "PairExcludeMask | None":

deepmd/pt_expt/model/make_model.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,19 @@ def _build_graph_for_method(
266266
Single owning site for the graph-builder dispatch shared by
267267
:meth:`_call_common_graph` and the graph Hessian wrapper
268268
(:class:`_WrapperForwardEnergyGraph`), so both build the graph identically.
269+
``"auto"`` is resolved device-aware via
270+
:func:`~deepmd.pt_expt.utils.neighbor_graph_method.resolve_auto_graph_builder`.
269271
"""
270272
from deepmd.dpmodel.utils.neighbor_graph import (
271273
build_neighbor_graph,
272274
build_neighbor_graph_ase,
273275
)
276+
from deepmd.pt_expt.utils.neighbor_graph_method import (
277+
resolve_auto_graph_builder,
278+
)
279+
280+
if method == "auto":
281+
method = resolve_auto_graph_builder(coord.device)
274282

275283
if method == "dense":
276284
return build_neighbor_graph(
@@ -297,7 +305,7 @@ def _build_graph_for_method(
297305
coord, atype, box, rcut, with_csr=with_csr, pair_excl=pair_excl
298306
)
299307
raise ValueError(
300-
f"unknown neighbor_graph_method {method!r}; use 'dense', 'ase', "
308+
f"unknown neighbor_graph_method {method!r}; use 'auto', 'dense', 'ase', "
301309
"'vesin', or 'nv'"
302310
)
303311

@@ -665,27 +673,35 @@ def forward_common_lower_graph(
665673
def _resolve_graph_method(
666674
self, neighbor_graph_method: str | None
667675
) -> str | None:
668-
"""pt_expt default-flip (decision #17): ``None`` => carry-all graph for
669-
graph-eligible mixed_types descriptors, else dense. Unlike dpmodel/jax,
670-
pt_expt has the autograd ``forward_common_lower_graph`` that produces
671-
force/virial on the graph, so the graph can be the DEFAULT here.
672-
``"legacy"`` forces dense; explicit ``"dense"``/``"ase"`` force the graph.
676+
"""pt_expt default-flip (decision #17): ``None`` / ``"auto"`` =>
677+
carry-all graph for graph-eligible mixed_types descriptors, with
678+
the concrete builder chosen by
679+
:func:`~deepmd.pt_expt.utils.neighbor_graph_method.resolve_auto_graph_builder`
680+
at build time. Unlike dpmodel/jax, pt_expt has the autograd
681+
``forward_common_lower_graph`` that produces force/virial on the
682+
graph, so the graph can be the DEFAULT here. ``"legacy"`` forces
683+
the dense-nlist path; explicit ``"dense"``/``"ase"``/``"vesin"``/
684+
``"nv"`` force that graph builder.
673685
674686
Parameters
675687
----------
676688
neighbor_graph_method
677-
The user-requested method: ``None`` (default-flip), ``"legacy"``
678-
(force dense), or ``"dense"``/``"ase"`` (force the graph builder).
689+
The user-requested method: ``None`` / ``"auto"`` (default-flip
690+
to the availability-probed O(N) builder), ``"legacy"`` (force
691+
dense nlist), or an explicit graph builder name.
679692
680693
Returns
681694
-------
682695
method
683696
The resolved method passed to :meth:`_call_common_graph`, or
684-
``None`` to take the dense path.
697+
``None`` to take the dense-nlist path. Eligible defaults return
698+
``"auto"`` (resolved device-aware in :func:`_build_graph_for_method`).
685699
"""
686700
if neighbor_graph_method == "legacy":
687701
return None
688-
if neighbor_graph_method is not None:
702+
# ``"auto"`` is synonymous with ``None`` for the eligibility gate;
703+
# both become the concrete builder only at graph-build time.
704+
if neighbor_graph_method not in (None, "auto"):
689705
return neighbor_graph_method
690706
# The DEFAULT-flip is gated to ENERGY-output models: the graph
691707
# lower itself is output-agnostic, but the compiled-training
@@ -703,7 +719,7 @@ def _resolve_graph_method(
703719
descriptor = getattr(self.atomic_model, "descriptor", None)
704720
uses_graph_lower = getattr(descriptor, "uses_graph_lower", lambda: False)
705721
if self.mixed_types() and uses_graph_lower():
706-
return "dense"
722+
return "auto"
707723
return None
708724

709725
def _call_common_graph(
@@ -736,7 +752,8 @@ def _call_common_graph(
736752
ap
737753
the atomic parameter. nf x nloc x nda
738754
method
739-
the carry-all builder, ``"dense"`` or ``"ase"``.
755+
the carry-all builder: ``"auto"``, ``"dense"``, ``"ase"``,
756+
``"vesin"``, or ``"nv"``.
740757
do_atomic_virial
741758
whether to calculate the atomic virial.
742759

deepmd/pt_expt/train/training.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,15 +1123,16 @@ def _forward_graph(
11231123
) -> dict[str, torch.Tensor]:
11241124
"""Carry-all GRAPH forward -> compiled ``forward_common_lower_graph``.
11251125
1126-
Builds the carry-all NeighborGraph eagerly (the SAME builder the eager
1127-
uncompiled default-flip uses, so the graph tensors are bit-identical),
1128-
then calls the compiled graph lower. The graph force is per-LOCAL-node
1129-
``(N, 3)`` with ``N == nframes * nloc`` for a single-rank carry-all graph,
1130-
so no extended->local scatter is needed; only the flat ``(N, *)`` node
1131-
keys are unravelled to ``(nf, nloc, *)`` at the I/O boundary.
1126+
Builds the carry-all NeighborGraph eagerly (the SAME auto-resolved
1127+
builder the eager uncompiled default-flip uses, so the graph tensors
1128+
match up to fp addition order), then calls the compiled graph lower.
1129+
The graph force is per-LOCAL-node ``(N, 3)`` with ``N == nframes * nloc``
1130+
for a single-rank carry-all graph, so no extended->local scatter is
1131+
needed; only the flat ``(N, *)`` node keys are unravelled to
1132+
``(nf, nloc, *)`` at the I/O boundary.
11321133
"""
1133-
from deepmd.dpmodel.utils.neighbor_graph import (
1134-
build_neighbor_graph,
1134+
from deepmd.pt_expt.model.make_model import (
1135+
_build_graph_for_method,
11351136
)
11361137

11371138
_model = self.original_model
@@ -1181,8 +1182,9 @@ def _forward_graph(
11811182
# level pair_exclude is a graph-BUILD transform (decision #18): fold it
11821183
# into edge_mask here so the compiled lower consumes a pre-excluded graph
11831184
# (the lower no longer re-applies it), matching the eager path exactly.
1185+
# ``"auto"`` resolves via resolve_auto_graph_builder(coord.device).
11841186
pair_excl = getattr(_model.atomic_model, "pair_excl", None)
1185-
ng = build_neighbor_graph(coord_3d, atype, box_flat, rcut, pair_excl=pair_excl)
1187+
ng = _build_graph_for_method("auto", coord_3d, atype, box_flat, rcut, pair_excl)
11861188
atype_flat = atype.reshape(nframes * nloc)
11871189

11881190
# Lazy compile of the GRAPH lower (cached per structure key).
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Shared NeighborGraph builder selection for pt_expt.
3+
4+
Single owner of the ``None`` / ``"auto"`` resolution ladder used by the
5+
model-level default-flip (:meth:`deepmd.pt_expt.model.make_model` /
6+
``_resolve_graph_method``), DeepEval graph-form ``.pt2`` inference, and
7+
compiled training's eager builder outside the traced lower.
8+
9+
Policy
10+
------
11+
* CUDA device: ``nv`` if importable, else ``vesin`` if importable, else
12+
``dense``.
13+
* CPU device: ``vesin`` if importable, else ``dense``.
14+
15+
``ase`` is never chosen automatically (explicit opt-in only). All builders
16+
emit the same carry-all neighbor set; the choice is performance-only.
17+
Builders run eagerly outside traced / compiled regions (export and training
18+
compile use synthetic dense graph inputs), so flipping the default does not
19+
change ``.pt2`` artifacts.
20+
21+
Perf note: at small benchmark sizes the dense all-pairs builder is typically
22+
not the bottleneck; the O(N) win appears for large systems (N of a few
23+
thousand atoms and up). Time builders manually on a large system to document
24+
the crossover before relying on auto for production throughput.
25+
"""
26+
27+
from __future__ import (
28+
annotations,
29+
)
30+
31+
import torch
32+
33+
from deepmd.pt.utils.nv_nlist import (
34+
is_nv_available,
35+
)
36+
from deepmd.pt_expt.utils.vesin_neighbor_list import (
37+
is_vesin_torch_available,
38+
)
39+
40+
41+
def resolve_auto_graph_builder(
42+
device: torch.device | str,
43+
) -> str:
44+
"""Resolve ``neighbor_graph_method`` ``None`` / ``"auto"`` to a concrete builder.
45+
46+
Parameters
47+
----------
48+
device
49+
Device the coordinates live on (or will be moved to). Controls whether
50+
the CUDA-only ``nv`` builder is eligible.
51+
52+
Returns
53+
-------
54+
str
55+
One of ``"nv"``, ``"vesin"``, or ``"dense"``.
56+
"""
57+
dev = torch.device(device)
58+
if dev.type == "cuda":
59+
if is_nv_available():
60+
return "nv"
61+
if is_vesin_torch_available():
62+
return "vesin"
63+
return "dense"
64+
if is_vesin_torch_available():
65+
return "vesin"
66+
return "dense"

deepmd/pt_expt/utils/nv_graph_builder.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
Unlike the vesin builder, nvalchemiops batches natively over frames via
1212
``batch_idx``/``batch_ptr`` -- a single GPU kernel handles all ``nf`` frames,
1313
so there is NO per-frame Python loop. CUDA-only ⇒ this module lives in pt_expt.
14+
On CUDA it is the first choice of
15+
:func:`~deepmd.pt_expt.utils.neighbor_graph_method.resolve_auto_graph_builder`
16+
when ``nvalchemiops`` is importable. Export / training compile still use
17+
synthetic dense graph inputs — the builder choice does not affect ``.pt2``.
1418
1519
The matrix decode mirrors :func:`deepmd.pt.utils.nv_nlist._matrix_to_extended_inputs`
1620
(the authoritative, tested extraction) but stops at the sparse ``(i, j, S)``

deepmd/pt_expt/utils/vesin_graph_builder.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
99
Scope note: ``vesin.torch``'s API is single-system, so this builder LOOPS over
1010
frames in Python (~1 ms/frame call overhead measured on GPU). It is intended
11-
for ``nf == 1`` inference and CPU use. It is never on a default hot path:
12-
``neighbor_graph_method=None`` resolves to the ``"dense"`` converter, and
13-
vesin is explicit opt-in only. For batched multi-frame GPU work prefer
14-
``nv`` (:mod:`.nv_graph_builder`), which batches all frames in one kernel.
11+
for ``nf == 1`` inference and CPU use. On CPU (and on CUDA when ``nv`` is
12+
unavailable) it is selected by the shared
13+
:func:`~deepmd.pt_expt.utils.neighbor_graph_method.resolve_auto_graph_builder`
14+
default ladder; otherwise prefer ``nv`` (:mod:`.nv_graph_builder`) for batched
15+
multi-frame GPU work, which batches all frames in one kernel. Export and
16+
training compile still use synthetic dense graph inputs — the builder choice
17+
does not affect ``.pt2`` artifacts.
1518
"""
1619

1720
from __future__ import (

source/tests/pt_expt/infer/test_graph_deepeval.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ def test_graph_pt2_deepeval_parity(graph_pt2, pbc, system) -> None:
252252
@pytest.mark.parametrize("pbc", [True, False]) # periodic vs non-periodic
253253
def test_graph_pt2_deepeval_vesin_matches_dense(graph_pt2, pbc) -> None:
254254
"""Selecting neighbor_graph_method='vesin' at DeepEval yields identical
255-
energy/force/virial to the default 'dense' builder on the SAME graph ``.pt2``
255+
energy/force/virial to the default 'auto' builder on the SAME graph ``.pt2``
256256
(the builder is a pure perf choice; neighbor sets are equal).
257257
"""
258258
pt2_path, _ = graph_pt2
@@ -261,7 +261,7 @@ def test_graph_pt2_deepeval_vesin_matches_dense(graph_pt2, pbc) -> None:
261261
max_nn = _max_neighbors(coords, box, atype)
262262
assert max_nn < SEL, "test system must be non-binding for carry-all parity"
263263

264-
dp_dense = DeepPot(pt2_path) # default neighbor_graph_method == "dense"
264+
dp_dense = DeepPot(pt2_path, neighbor_graph_method="dense")
265265
dp_vesin = DeepPot(pt2_path, neighbor_graph_method="vesin")
266266
assert dp_vesin.deep_eval._neighbor_graph_method == "vesin"
267267

@@ -272,6 +272,37 @@ def test_graph_pt2_deepeval_vesin_matches_dense(graph_pt2, pbc) -> None:
272272
np.testing.assert_allclose(v_v, v_d, rtol=1e-10, atol=1e-10, err_msg="virial")
273273

274274

275+
@pytest.mark.parametrize("pbc", [True, False])
276+
def test_graph_pt2_deepeval_auto_matches_dense(graph_pt2, pbc) -> None:
277+
"""Default / ``neighbor_graph_method='auto'`` match explicit ``'dense'``.
278+
279+
Builders are value-transparent; auto only changes which O(N) backend is
280+
used when available. Export still uses synthetic dense inputs, so the
281+
``.pt2`` itself is unaffected by the auto default.
282+
"""
283+
pt2_path, _ = graph_pt2
284+
coords, cells, atype = _build_system(**_SYSTEMS["small_8"])
285+
box = cells if pbc else None
286+
max_nn = _max_neighbors(coords, box, atype)
287+
assert max_nn < SEL, "test system must be non-binding for carry-all parity"
288+
289+
dp_default = DeepPot(pt2_path)
290+
dp_auto = DeepPot(pt2_path, neighbor_graph_method="auto")
291+
dp_dense = DeepPot(pt2_path, neighbor_graph_method="dense")
292+
assert dp_default.deep_eval._neighbor_graph_method == "auto"
293+
assert dp_auto.deep_eval._neighbor_graph_method == "auto"
294+
295+
e_def, f_def, v_def = dp_default.eval(coords, box, atype)
296+
e_auto, f_auto, v_auto = dp_auto.eval(coords, box, atype)
297+
e_d, f_d, v_d = dp_dense.eval(coords, box, atype)
298+
np.testing.assert_allclose(e_def, e_d, rtol=1e-10, atol=1e-10, err_msg="energy")
299+
np.testing.assert_allclose(f_def, f_d, rtol=1e-10, atol=1e-10, err_msg="force")
300+
np.testing.assert_allclose(v_def, v_d, rtol=1e-10, atol=1e-10, err_msg="virial")
301+
np.testing.assert_allclose(e_auto, e_d, rtol=1e-10, atol=1e-10, err_msg="energy")
302+
np.testing.assert_allclose(f_auto, f_d, rtol=1e-10, atol=1e-10, err_msg="force")
303+
np.testing.assert_allclose(v_auto, v_d, rtol=1e-10, atol=1e-10, err_msg="virial")
304+
305+
275306
def test_graph_pt2_single_atom_no_edges(graph_pt2) -> None:
276307
"""A single isolated atom (zero real edges) evaluates through the ``.pt2``.
277308

0 commit comments

Comments
 (0)