Skip to content

Commit be614c7

Browse files
committed
fix(pt_expt): run cell graph search on CPU
1 parent 8c1d7e8 commit be614c7

3 files changed

Lines changed: 79 additions & 16 deletions

File tree

deepmd/pt_expt/infer/deep_eval.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ class DeepEval(DeepEvalBackend):
189189
at each eval call (CUDA: ``nv`` if importable; else ``vesin`` only when
190190
``nf == 1`` and importable; else ``dense``). Explicit
191191
``"dense"`` / ``"ase"`` / ``"cell"`` / ``"vesin"`` / ``"nv"``
192-
choices are preserved.
192+
choices are preserved. The CPU-only ``cell`` search runs on the host;
193+
its graph tensors are transferred to the model device before inference.
193194
A non-default value on any other artifact raises at construction because
194195
the knob would silently do nothing there; use ``nlist_backend`` for the
195196
nlist path instead. All builders emit the same neighbor set, so the
@@ -2558,11 +2559,13 @@ def _build_eval_graph(
25582559
call-time via
25592560
:func:`~deepmd.pt_expt.utils.graph_builder.resolve_auto_graph_builder`
25602561
using the batch frame count (vesin only when ``nf == 1``);
2561-
``dense``/``ase`` run backend-agnostic (numpy); ``cell``/``vesin``/``nv``
2562-
run on-device (torch, O(N)), and ``cell`` threads its search. All backends emit the SAME neighbor set
2562+
``dense``/``ase`` run backend-agnostic (numpy), ``cell`` runs its
2563+
threaded search on the CPU, and ``vesin``/``nv`` run on the requested
2564+
device (torch, O(N)). All backends emit the SAME neighbor set
25632565
(carry-all, sel-free), so the selection is a pure performance choice
25642566
and results are unchanged. The result is canonicalized to the
2565-
destination-major graph-form ``.pt2`` ABI after construction.
2567+
destination-major graph-form ``.pt2`` ABI after construction; the
2568+
caller transfers its fields to the model device.
25662569
"""
25672570
method = self._neighbor_graph_method
25682571
if method == "auto":
@@ -2574,6 +2577,7 @@ def _build_eval_graph(
25742577
# pre-excluded ``edge_mask`` and never re-applies it (mirrors the C++
25752578
# ``applyPairExclusion`` and the eager dpmodel/pt_expt build path).
25762579
pair_excl = self._model_pair_excl()
2580+
builder_device = torch.device("cpu") if method == "cell" else device
25772581
# The fused builder writes the whole destination-major payload from one
25782582
# search. It applies only where nothing has to be filtered or masked
25792583
# afterwards, because it has no stage in which to do so, and only for
@@ -2598,17 +2602,17 @@ def _build_eval_graph(
25982602
torch.as_tensor(
25992603
np.asarray(coord_input).reshape(-1, 3),
26002604
dtype=torch.float64,
2601-
device=device,
2605+
device=builder_device,
26022606
),
26032607
torch.as_tensor(
26042608
np.asarray(atom_types).reshape(-1),
26052609
dtype=torch.int64,
2606-
device=device,
2610+
device=builder_device,
26072611
),
26082612
torch.as_tensor(
26092613
np.asarray(box_input).reshape(3, 3),
26102614
dtype=torch.float64,
2611-
device=device,
2615+
device=builder_device,
26122616
)
26132617
if box_input is not None
26142618
else None,
@@ -2642,12 +2646,14 @@ def _build_eval_graph(
26422646
pair_excl=pair_excl,
26432647
)
26442648
if method in ("cell", "vesin", "nv"):
2645-
cc = torch.as_tensor(coord_input, dtype=torch.float64, device=device)
2649+
cc = torch.as_tensor(
2650+
coord_input, dtype=torch.float64, device=builder_device
2651+
)
26462652
aa = torch.as_tensor(
2647-
np.asarray(atom_types), dtype=torch.int64, device=device
2653+
np.asarray(atom_types), dtype=torch.int64, device=builder_device
26482654
)
26492655
bb = (
2650-
torch.as_tensor(box_input, dtype=torch.float64, device=device)
2656+
torch.as_tensor(box_input, dtype=torch.float64, device=builder_device)
26512657
if box_input is not None
26522658
else None
26532659
)
@@ -2703,10 +2709,10 @@ def _model_pair_excl(self) -> "PairExcludeMask | None":
27032709
FRESH numpy-backed mask.
27042710
27052711
A numpy ``type_mask`` converts cleanly onto whichever namespace/device the
2706-
builder's ``atype`` uses (dense/ase pass numpy; vesin/nv pass torch). The
2707-
dpmodel's own ``pair_excl`` is NOT reused: as a pt_expt module attribute
2708-
its ``type_mask`` is a torch (possibly CUDA) buffer, which cannot convert
2709-
to a numpy ``atype`` on the dense/ase build path.
2712+
builder's ``atype`` uses (dense/ase pass numpy; cell/vesin/nv pass torch).
2713+
The dpmodel's own ``pair_excl`` is NOT reused: as a pt_expt module
2714+
attribute its ``type_mask`` is a torch (possibly CUDA) buffer, which
2715+
cannot convert to a numpy ``atype`` on the dense/ase build path.
27102716
27112717
Returns
27122718
-------

deepmd/pt_expt/utils/cell_graph_builder.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
algorithm threaded over destination atoms, and it emits its pairs
99
destination-grouped, which is the order the compressed-sparse-row views want.
1010
11-
The builder is CPU-only by construction. CUDA hosts keep the ``nv`` builder,
12-
whose search already runs on the device.
11+
The builder is CPU-only by construction. Automatic CUDA selection keeps the
12+
``nv`` builder, whose search already runs on the device. An explicit ``cell``
13+
selection searches on the host and transfers the completed graph to CUDA.
1314
"""
1415

1516
from __future__ import (

source/tests/pt_expt/infer/test_deep_eval_pt_checkpoint.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,62 @@ def test_auto_resolution(self) -> None:
509509
self.assertEqual(actual, expected)
510510

511511

512+
class TestCellGraphDeviceRouting(unittest.TestCase):
513+
"""The CPU-only cell search must not receive CUDA tensors."""
514+
515+
@staticmethod
516+
def _make_evaluator() -> PtExptDeepEval:
517+
evaluator = object.__new__(PtExptDeepEval)
518+
evaluator._neighbor_graph_method = "cell"
519+
evaluator._rcut = 3.0
520+
evaluator.metadata = {"graph_edge_dtype": "float32"}
521+
return evaluator
522+
523+
def test_fused_cell_builder_uses_cpu(self) -> None:
524+
"""The single-frame fused cell builder receives CPU inputs."""
525+
evaluator = self._make_evaluator()
526+
expected = mock.sentinel.graph
527+
with (
528+
mock.patch.object(evaluator, "_model_pair_excl", return_value=None),
529+
mock.patch(
530+
"deepmd.pt_expt.utils.cell_graph_builder.build_neighbor_graph_fused",
531+
return_value=expected,
532+
) as builder,
533+
):
534+
actual = evaluator._build_eval_graph(
535+
np.zeros((1, 6)),
536+
np.zeros((1, 2), dtype=np.int64),
537+
np.eye(3).reshape(1, 9),
538+
torch.device("cuda"),
539+
)
540+
541+
self.assertIs(actual, expected)
542+
for value in builder.call_args.args[:3]:
543+
self.assertEqual(value.device.type, "cpu")
544+
545+
def test_general_cell_builder_uses_cpu(self) -> None:
546+
"""The batched cell builder receives CPU inputs."""
547+
evaluator = self._make_evaluator()
548+
expected = mock.sentinel.graph
549+
with (
550+
mock.patch.object(evaluator, "_model_pair_excl", return_value=None),
551+
mock.patch(
552+
"deepmd.pt_expt.utils.cell_graph_builder.build_neighbor_graph_cell",
553+
return_value=expected,
554+
) as builder,
555+
):
556+
actual = evaluator._build_eval_graph(
557+
np.zeros((2, 6)),
558+
np.zeros((2, 2), dtype=np.int64),
559+
np.tile(np.eye(3).reshape(1, 9), (2, 1)),
560+
torch.device("cuda"),
561+
)
562+
563+
self.assertIs(actual, expected)
564+
for value in builder.call_args.args[:3]:
565+
self.assertEqual(value.device.type, "cpu")
566+
567+
512568
class TestPtExptLoadPtGraphDPA1(unittest.TestCase):
513569
"""Raw DPA1 checkpoints retain the source model's graph-forward semantics."""
514570

0 commit comments

Comments
 (0)