Skip to content

Commit be6d9bb

Browse files
committed
feat(pt_expt): call-time DeepEval auto ladder with nf==1 vesin gate
Extract resolve_auto_graph_builder for inference and select vesin only for single-frame batches, matching _select_neighbor_builder. Multi-frame auto stays on nv/dense so auto_batch_size does not hit the per-frame loop. Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>
1 parent a3195b0 commit be6d9bb

5 files changed

Lines changed: 189 additions & 52 deletions

File tree

deepmd/pt_expt/infer/deep_eval.py

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
22
import json
3-
import logging
43
import warnings
54
from collections.abc import (
65
Callable,
@@ -80,8 +79,6 @@
8079
NeighborGraph,
8180
)
8281

83-
log = logging.getLogger(__name__)
84-
8582

8683
# Public output keys emitted by graph-lower forwards, keyed by the
8784
# output-variable category that ``request_defs`` carries. The graph path is
@@ -192,15 +189,17 @@ class DeepEval(DeepEvalBackend):
192189
neighbor_graph_method : str, default: "auto"
193190
Carry-all graph builder for graph-form ``.pt2`` artifacts and
194191
graph-routed ``.pt`` checkpoints
195-
(``metadata["lower_input_kind"] == "graph"``): ``"auto"`` selects
196-
``"nv"`` on CUDA when nvalchemiops is available and otherwise falls
197-
back to ``"dense"``. ``"vesin"`` remains explicit opt-in because it
198-
loops over frames in Python. Explicit
192+
(``metadata["lower_input_kind"] == "graph"``): ``"auto"`` selects via
193+
:func:`~deepmd.pt_expt.utils.graph_builder.resolve_auto_graph_builder`
194+
at each eval call (CUDA: ``nv`` if importable; else ``vesin`` only when
195+
``nf == 1`` and importable; else ``dense``). Explicit
199196
``"dense"`` / ``"ase"`` / ``"vesin"`` / ``"nv"`` choices are preserved.
200197
A non-default value on any other artifact raises at construction because
201198
the knob would silently do nothing there; use ``nlist_backend`` for the
202199
nlist path instead. All builders emit the same neighbor set, so the
203-
choice is performance-only. Consolidating the two knobs into a single
200+
choice is performance-only. Training keeps a separate auto policy
201+
(:func:`~deepmd.pt_expt.utils.graph_builder.resolve_neighbor_graph_method`)
202+
that never selects ``vesin``. Consolidating the two knobs into a single
204203
backend-selection API is deferred to the dense-nlist deprecation.
205204
**kwargs : dict
206205
Keyword arguments.
@@ -271,33 +270,31 @@ def __init__(
271270
raise TypeError("auto_batch_size should be bool, int, or AutoBatchSize")
272271

273272
@staticmethod
274-
def _resolve_neighbor_graph_method(method: str) -> str:
275-
"""Resolve the graph builder once for the active device."""
273+
def _resolve_neighbor_graph_method(method: str, nf: int | None = None) -> str:
274+
"""Validate and optionally resolve the graph builder for the active device.
275+
276+
``"auto"`` is left unresolved when ``nf`` is omitted so construction-
277+
time setup can defer to :meth:`_build_eval_graph`, where the frame
278+
count is known and vesin can be gated on ``nf == 1``.
279+
"""
276280
if method not in ("auto", "dense", "ase", "vesin", "nv"):
277281
raise ValueError(
278282
f"Unknown neighbor_graph_method {method!r}; "
279283
"expected 'auto', 'dense', 'ase', 'vesin', or 'nv'."
280284
)
281285
if method != "auto":
282286
return method
287+
if nf is None:
288+
return "auto"
283289

284-
from deepmd.pt.utils.nv_nlist import (
285-
is_nv_available,
286-
)
287290
from deepmd.pt_expt.utils.env import (
288291
DEVICE,
289292
)
293+
from deepmd.pt_expt.utils.graph_builder import (
294+
resolve_auto_graph_builder,
295+
)
290296

291-
if DEVICE.type == "cuda":
292-
if is_nv_available():
293-
return "nv"
294-
log.warning(
295-
"nvalchemi-toolkit-ops is unavailable; falling back from "
296-
"neighbor_graph_method='auto' to the dense graph builder. "
297-
"Install it with `pip install nvalchemi-toolkit-ops` to enable "
298-
"the NV graph builder."
299-
)
300-
return "dense"
297+
return resolve_auto_graph_builder(DEVICE, nf)
301298

302299
def _setup_neighbor_backend(self, nlist_backend: str) -> None:
303300
"""Resolve the graph or neighbor-list construction strategy.
@@ -2316,14 +2313,21 @@ def _build_eval_graph(
23162313
) -> "NeighborGraph":
23172314
"""Build the carry-all NeighborGraph for graph-lower inference.
23182315
2319-
Dispatches on ``self._neighbor_graph_method``: ``dense``/``ase`` run
2320-
backend-agnostic (numpy); ``vesin``/``nv`` run on-device (torch, O(N)).
2321-
All backends emit the SAME neighbor set (carry-all, sel-free), so the
2322-
selection is a pure performance choice and results are unchanged. The
2323-
result is canonicalized to the destination-major graph-form ``.pt2``
2324-
ABI after construction.
2316+
Dispatches on ``self._neighbor_graph_method``: ``auto`` is resolved
2317+
call-time via
2318+
:func:`~deepmd.pt_expt.utils.graph_builder.resolve_auto_graph_builder`
2319+
using the batch frame count (vesin only when ``nf == 1``);
2320+
``dense``/``ase`` run backend-agnostic (numpy); ``vesin``/``nv`` run
2321+
on-device (torch, O(N)). All backends emit the SAME neighbor set
2322+
(carry-all, sel-free), so the selection is a pure performance choice
2323+
and results are unchanged. The result is canonicalized to the
2324+
destination-major graph-form ``.pt2`` ABI after construction.
23252325
"""
23262326
method = self._neighbor_graph_method
2327+
if method == "auto":
2328+
coord_arr = np.asarray(coord_input)
2329+
nf = int(coord_arr.shape[0]) if coord_arr.ndim >= 2 else 1
2330+
method = self._resolve_neighbor_graph_method("auto", nf=nf)
23272331
# Model-level ``pair_exclude_types`` is a graph-BUILD transform
23282332
# (decision #18): apply it here so the exported ``.pt2`` lower consumes a
23292333
# pre-excluded ``edge_mask`` and never re-applies it (mirrors the C++
@@ -2392,7 +2396,7 @@ def _build_eval_graph(
23922396
)
23932397
raise ValueError(
23942398
f"unknown neighbor_graph_method {method!r}; "
2395-
"use 'dense', 'ase', 'vesin', or 'nv'"
2399+
"use 'auto', 'dense', 'ase', 'vesin', or 'nv'"
23962400
)
23972401

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

deepmd/pt_expt/utils/graph_builder.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,75 @@
1919
log = logging.getLogger(__name__)
2020

2121

22+
def resolve_auto_graph_builder(
23+
device: torch.device | str,
24+
nf: int = 1,
25+
) -> str:
26+
"""Resolve ``neighbor_graph_method="auto"`` to a concrete inference builder.
27+
28+
Single owner of the inference / DeepEval auto ladder. Training uses
29+
:func:`resolve_neighbor_graph_method`, which never selects ``vesin``.
30+
31+
Mirrors :func:`deepmd.pt.model.model.sezm_model._select_neighbor_builder`:
32+
``vesin`` is eligible only for a single-frame batch (``nf == 1``), because
33+
its API loops frames in Python (~1 ms/frame). Multi-frame batches stay on
34+
``nv`` (CUDA) or ``dense`` so ``auto_batch_size`` / ``dp test`` do not
35+
regress to the per-frame loop.
36+
37+
Policy
38+
------
39+
* CUDA + ``nvalchemiops``: ``nv`` (any ``nf``).
40+
* ``nf == 1`` + ``vesin.torch``: ``vesin``.
41+
* otherwise: ``dense``.
42+
43+
``ase`` is never chosen automatically. All builders emit the same carry-all
44+
neighbor set; the choice is performance-only. Builders run eagerly outside
45+
traced / compiled regions, so this does not change ``.pt2`` artifacts.
46+
47+
Parameters
48+
----------
49+
device : torch.device or str
50+
Device the coordinates live on (or will be moved to). Controls whether
51+
the CUDA-only ``nv`` builder is eligible.
52+
nf : int, default: 1
53+
Number of frames in the batch. ``vesin`` is selected only when
54+
``nf == 1`` and ``vesin.torch`` is importable.
55+
56+
Returns
57+
-------
58+
str
59+
One of ``"nv"``, ``"vesin"``, or ``"dense"``.
60+
"""
61+
from deepmd.pt.utils.nv_nlist import (
62+
is_nv_available,
63+
)
64+
from deepmd.pt_expt.utils.vesin_neighbor_list import (
65+
is_vesin_torch_available,
66+
)
67+
68+
if nf < 1:
69+
raise ValueError(f"nf must be >= 1, got {nf}")
70+
71+
dev = torch.device(device)
72+
if dev.type == "cuda" and is_nv_available():
73+
return "nv"
74+
if nf == 1 and is_vesin_torch_available():
75+
return "vesin"
76+
if dev.type == "cuda" and not is_nv_available():
77+
log.warning(
78+
"nvalchemi-toolkit-ops is unavailable; falling back from "
79+
"neighbor_graph_method='auto' to the dense graph builder"
80+
+ (
81+
""
82+
if nf == 1
83+
else " (vesin is not used for nf>1; its API loops frames in Python)"
84+
)
85+
+ ". Install it with `pip install nvalchemi-toolkit-ops` to enable "
86+
"the NV graph builder."
87+
)
88+
return "dense"
89+
90+
2291
def resolve_neighbor_graph_method(
2392
requested: str,
2493
device: torch.device,
@@ -36,6 +105,8 @@ def resolve_neighbor_graph_method(
36105
-------
37106
str
38107
The concrete builder name, either ``"dense"`` or ``"nv"``.
108+
Training auto never selects ``vesin`` (per-frame Python loop); use
109+
:func:`resolve_auto_graph_builder` for inference auto selection.
39110
40111
Raises
41112
------

deepmd/pt_expt/utils/vesin_graph_builder.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
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. Inference ``neighbor_graph_method="auto"``
12+
(:func:`~deepmd.pt_expt.utils.graph_builder.resolve_auto_graph_builder`) selects
13+
vesin only when ``nf == 1`` and ``vesin.torch`` is importable (and ``nv`` is
14+
unavailable on CUDA); multi-frame batches stay on ``nv``/``dense``. Training
15+
auto never selects vesin. Prefer ``nv`` (:mod:`.nv_graph_builder`) for batched
16+
multi-frame GPU work, which batches all frames in one kernel.
1517
"""
1618

1719
from __future__ import (

source/tests/pt_expt/infer/test_deep_eval_pt_checkpoint.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -422,16 +422,31 @@ def test_unsupported_extension_raises(self) -> None:
422422
class TestNeighborGraphMethodResolution(unittest.TestCase):
423423
"""Auto graph-builder selection must cover each host policy explicitly."""
424424

425+
def test_auto_deferred_until_nf_known(self) -> None:
426+
"""Construction-time resolve leaves ``auto`` unresolved without ``nf``."""
427+
self.assertEqual(
428+
PtExptDeepEval._resolve_neighbor_graph_method("auto"),
429+
"auto",
430+
)
431+
425432
def test_auto_resolution(self) -> None:
433+
# (device, nv, vesin, nf, expected, warns)
426434
cases = (
427-
("cpu", False, "dense", False),
428-
("cuda", True, "nv", False),
429-
("cuda", False, "dense", True),
430-
)
431-
for device_type, nv_available, expected, warns in cases:
435+
("cpu", False, True, 1, "vesin", False),
436+
("cpu", False, True, 4, "dense", False),
437+
("cpu", False, False, 1, "dense", False),
438+
("cuda", True, True, 1, "nv", False),
439+
("cuda", True, True, 4, "nv", False),
440+
("cuda", False, True, 1, "vesin", False),
441+
("cuda", False, True, 4, "dense", True),
442+
("cuda", False, False, 1, "dense", True),
443+
)
444+
for device_type, nv_available, vesin_available, nf, expected, warns in cases:
432445
with self.subTest(
433446
device_type=device_type,
434447
nv_available=nv_available,
448+
vesin_available=vesin_available,
449+
nf=nf,
435450
):
436451
with (
437452
mock.patch(
@@ -442,17 +457,23 @@ def test_auto_resolution(self) -> None:
442457
"deepmd.pt.utils.nv_nlist.is_nv_available",
443458
return_value=nv_available,
444459
),
460+
mock.patch(
461+
"deepmd.pt_expt.utils.vesin_neighbor_list.is_vesin_torch_available",
462+
return_value=vesin_available,
463+
),
445464
):
446465
if warns:
447466
with self.assertLogs(
448-
"deepmd.pt_expt.infer.deep_eval",
467+
"deepmd.pt_expt.utils.graph_builder",
449468
level="WARNING",
450469
):
451470
actual = PtExptDeepEval._resolve_neighbor_graph_method(
452-
"auto"
471+
"auto", nf=nf
453472
)
454473
else:
455-
actual = PtExptDeepEval._resolve_neighbor_graph_method("auto")
474+
actual = PtExptDeepEval._resolve_neighbor_graph_method(
475+
"auto", nf=nf
476+
)
456477
self.assertEqual(actual, expected)
457478

458479

source/tests/pt_expt/model/test_graph_builder_dispatch.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,20 @@ def _make_model():
6767
return EnergyModel(ds, ft, type_map=["O", "H"]).to(env.DEVICE)
6868

6969

70-
def _eval(model, method):
70+
def _eval(model, method, nf: int = 1):
7171
rng = np.random.default_rng(0)
7272
coord = torch.tensor(
73-
rng.random((1, 6, 3)) * 4.0, dtype=torch.float64, device=env.DEVICE
73+
rng.random((nf, 6, 3)) * 4.0, dtype=torch.float64, device=env.DEVICE
74+
)
75+
atype = torch.tensor(
76+
[[0, 1, 1, 0, 1, 1]] * nf, dtype=torch.int64, device=env.DEVICE
77+
)
78+
box = (
79+
(torch.eye(3, dtype=torch.float64, device=env.DEVICE) * 6.0)
80+
.reshape(1, 3, 3)
81+
.expand(nf, 3, 3)
82+
.clone()
7483
)
75-
atype = torch.tensor([[0, 1, 1, 0, 1, 1]], dtype=torch.int64, device=env.DEVICE)
76-
box = (torch.eye(3, dtype=torch.float64, device=env.DEVICE) * 6.0).reshape(1, 3, 3)
7784
ret = model.forward_common(coord, atype, box, neighbor_graph_method=method)
7885
# graph path returns the output-agnostic dict (no translated force/virial);
7986
# energy_redu = total energy, energy_derv_r = d energy / d coord (force parity)
@@ -138,12 +145,43 @@ def test_explicit_nv_rejects_cpu():
138145
resolve_neighbor_graph_method("nv", torch.device("cpu"))
139146

140147

148+
@pytest.mark.parametrize(
149+
("device", "nv", "vesin", "nf", "expected"),
150+
[
151+
("cpu", False, True, 1, "vesin"),
152+
("cpu", False, True, 4, "dense"),
153+
("cpu", True, False, 1, "dense"),
154+
("cuda", True, True, 1, "nv"),
155+
("cuda", True, True, 4, "nv"),
156+
("cuda", False, True, 1, "vesin"),
157+
("cuda", False, True, 4, "dense"),
158+
("cuda", False, False, 1, "dense"),
159+
],
160+
)
161+
def test_resolve_auto_graph_builder_ladder(
162+
device: str, nv: bool, vesin: bool, nf: int, expected: str
163+
) -> None:
164+
from deepmd.pt_expt.utils.graph_builder import (
165+
resolve_auto_graph_builder,
166+
)
167+
168+
with (
169+
patch("deepmd.pt.utils.nv_nlist.is_nv_available", return_value=nv),
170+
patch(
171+
"deepmd.pt_expt.utils.vesin_neighbor_list.is_vesin_torch_available",
172+
return_value=vesin,
173+
),
174+
):
175+
assert resolve_auto_graph_builder(device, nf=nf) == expected
176+
177+
141178
@pytest.mark.skipif(not is_vesin_torch_available(), reason="vesin[torch] not installed")
142-
def test_vesin_matches_dense_energy_force():
179+
@pytest.mark.parametrize("nf", [1, 4])
180+
def test_vesin_matches_dense_energy_force(nf: int):
143181
torch.manual_seed(0)
144182
model = _make_model()
145-
e_d, f_d = _eval(model, "dense")
146-
e_v, f_v = _eval(model, "vesin")
183+
e_d, f_d = _eval(model, "dense", nf=nf)
184+
e_v, f_v = _eval(model, "vesin", nf=nf)
147185
tol = 1e-12 if env.DEVICE.type == "cpu" else 1e-10
148186
torch.testing.assert_close(e_v, e_d, rtol=tol, atol=tol)
149187
torch.testing.assert_close(f_v, f_d, rtol=tol, atol=tol)
@@ -153,11 +191,12 @@ def test_vesin_matches_dense_energy_force():
153191
not (torch.cuda.is_available() and is_nv_available()),
154192
reason="nvalchemiops requires CUDA + nvalchemi-toolkit-ops",
155193
)
156-
def test_nv_matches_dense_energy_force():
194+
@pytest.mark.parametrize("nf", [1, 4])
195+
def test_nv_matches_dense_energy_force(nf: int):
157196
torch.manual_seed(0)
158197
model = _make_model()
159-
e_d, f_d = _eval(model, "dense")
160-
e_n, f_n = _eval(model, "nv")
198+
e_d, f_d = _eval(model, "dense", nf=nf)
199+
e_n, f_n = _eval(model, "nv", nf=nf)
161200
tol = 1e-10 # CUDA fp64: absorbs scatter-atomic / index_add nondeterminism
162201
torch.testing.assert_close(e_n, e_d, rtol=tol, atol=tol)
163202
torch.testing.assert_close(f_n, f_d, rtol=tol, atol=tol)

0 commit comments

Comments
 (0)