Skip to content

Commit 8b41231

Browse files
committed
fix(dpmodel): handle cross-namespace device arrays
1 parent 0538fb8 commit 8b41231

7 files changed

Lines changed: 140 additions & 23 deletions

File tree

deepmd/dpmodel/array_api.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
Version,
1212
)
1313

14+
from deepmd.dpmodel.common import (
15+
to_numpy_array,
16+
)
17+
1418
# Type alias for array_api compatible arrays
1519
Array = np.ndarray | Any # Any to support JAX, PyTorch, etc. arrays
1620

@@ -27,22 +31,28 @@ def xp_asarray_nodetach(
2731
``torch.asarray`` detaches its input from the autograd graph, so calling
2832
``xp.asarray`` on a weight attribute that is already a backend tensor
2933
(e.g. a ``torch.nn.Parameter`` registered by the pt_expt backend)
30-
silently breaks gradient flow to that weight. This helper converts
31-
genuine non-backend data (numpy arrays, python scalars/lists) via
32-
``xp.asarray``; backend tensors are returned as-is, with an optional
33-
differentiable dtype cast via ``xp.astype``.
34-
35-
The ``device`` argument only applies to the conversion path: backend
36-
tensors are assumed to already live on the working device (they are
37-
created together with the inputs).
34+
silently breaks gradient flow to that weight. Backend tensors already in
35+
``xp`` are therefore returned as-is, with an optional differentiable dtype
36+
cast via ``xp.astype``.
37+
38+
An array from another namespace cannot retain its autograd graph. It is
39+
converted through NumPy before entering ``xp``; this also performs the
40+
required device-to-host copy when a CUDA-backed model constant is consumed
41+
by a NumPy statistics path.
42+
43+
The ``device`` argument only applies to the conversion path. Arrays already
44+
in ``xp`` are assumed to live on the working device because model buffers
45+
and inputs are moved together.
3846
"""
39-
if isinstance(obj, np.ndarray) or not array_api_compat.is_array_api_obj(obj):
40-
if dtype is None:
41-
return xp.asarray(obj, device=device)
42-
return xp.asarray(obj, dtype=dtype, device=device)
43-
if dtype is not None and obj.dtype != dtype:
44-
obj = xp.astype(obj, dtype)
45-
return obj
47+
if array_api_compat.is_array_api_obj(obj):
48+
if array_api_compat.array_namespace(obj) is xp:
49+
if dtype is not None and obj.dtype != dtype:
50+
obj = xp.astype(obj, dtype)
51+
return obj
52+
obj = to_numpy_array(obj)
53+
if dtype is None:
54+
return xp.asarray(obj, device=device)
55+
return xp.asarray(obj, dtype=dtype, device=device)
4656

4757

4858
# array api adds take_along_axis in https://github.com/data-apis/array-api/pull/816

deepmd/dpmodel/descriptor/dpa4.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2186,7 +2186,7 @@ def _canonicalize_charge_spin(
21862186
raise ValueError("`charge_spin` is required for this SeZM descriptor.")
21872187
charge_spin = xp.reshape(
21882188
xp_asarray_nodetach(
2189-
xp, np.asarray(self.default_chg_spin), dtype=dtype, device=device
2189+
xp, self.default_chg_spin, dtype=dtype, device=device
21902190
),
21912191
(1, 2),
21922192
)

deepmd/dpmodel/model/spin_model.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414

1515
from deepmd.dpmodel.array_api import (
1616
Array,
17+
xp_asarray_nodetach,
1718
)
1819
from deepmd.dpmodel.atomic_model.dp_atomic_model import (
1920
DPAtomicModel,
2021
)
2122
from deepmd.dpmodel.common import (
2223
NativeOP,
23-
to_numpy_array,
2424
)
2525
from deepmd.dpmodel.model.base_model import (
2626
BaseModel,
@@ -140,9 +140,11 @@ def __init__(
140140

141141
def _to_xp(self, arr: Any, xp: Any, ref_arr: Any) -> Any:
142142
"""Convert an array to the namespace and device of ``ref_arr``."""
143-
if array_api_compat.is_numpy_namespace(xp):
144-
arr = to_numpy_array(arr)
145-
return xp.asarray(arr, device=array_api_compat.device(ref_arr))
143+
return xp_asarray_nodetach(
144+
xp,
145+
arr,
146+
device=array_api_compat.device(ref_arr),
147+
)
146148

147149
def _lookup_type_values(self, values: Any, atype: Array, ref_arr: Array) -> Array:
148150
"""Gather per-type values while mapping virtual atom types to zero.

deepmd/dpmodel/utils/exclude_mask.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from deepmd.dpmodel.array_api import (
77
Array,
8+
xp_asarray_nodetach,
89
xp_take_along_axis,
910
xp_take_first_n,
1011
)
@@ -55,7 +56,11 @@ def build_type_exclude_mask(
5556
lead = atype.shape # (nf, natom) dense | (N,) graph
5657
return xp.reshape(
5758
xp.take(
58-
xp.asarray(self.type_mask[...], device=array_api_compat.device(atype)),
59+
xp_asarray_nodetach(
60+
xp,
61+
self.type_mask[...],
62+
device=array_api_compat.device(atype),
63+
),
5964
xp.reshape(atype, (-1,)),
6065
axis=0,
6166
),
@@ -151,7 +156,11 @@ def build_type_exclude_mask(
151156
type_ij_flat = xp.reshape(type_ij, (-1,))
152157
mask = xp.reshape(
153158
xp.take(
154-
xp.asarray(self.type_mask[...], device=array_api_compat.device(nlist)),
159+
xp_asarray_nodetach(
160+
xp,
161+
self.type_mask[...],
162+
device=array_api_compat.device(nlist),
163+
),
155164
type_ij_flat,
156165
),
157166
(nf, nloc, nnei),
@@ -185,7 +194,11 @@ def build_edge_exclude_mask(self, edge_index: Array, atype: Array) -> Array:
185194
dst_t = xp.take(atype, edge_index[1, :], axis=0)
186195
type_ij = dst_t * (self.ntypes + 1) + src_t
187196
return xp.take(
188-
xp.asarray(self.type_mask[...], device=array_api_compat.device(atype)),
197+
xp_asarray_nodetach(
198+
xp,
199+
self.type_mask[...],
200+
device=array_api_compat.device(atype),
201+
),
189202
type_ij,
190203
axis=0,
191204
)

source/tests/consistent/test_array_api.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
22
import sys
33
import unittest
4+
from unittest.mock import (
5+
patch,
6+
)
47

8+
import array_api_compat
59
import numpy as np
610

711
from deepmd.dpmodel.array_api import (
812
xp_add_at,
13+
xp_asarray_nodetach,
914
xp_bincount,
1015
xp_maximum_at,
1116
xp_scatter_sum,
@@ -56,6 +61,36 @@ def test_torch_parameter_requires_grad(self) -> None:
5661
self.assertTrue(param.requires_grad)
5762
self.assertEqual(param.device, DEVICE)
5863

64+
@unittest.skipUnless(INSTALLED_PT, "PyTorch is not installed")
65+
def test_foreign_tensor_is_converted_to_numpy_namespace(self) -> None:
66+
tensor = torch.tensor([1.0, 2.0], dtype=torch.float64, device=DEVICE)
67+
numpy_namespace = array_api_compat.array_namespace(np.empty(0))
68+
69+
# A CUDA tensor rejects the direct NumPy protocol. Simulate that
70+
# boundary on CPU so the device-to-host fallback is exercised on every
71+
# platform.
72+
with patch.object(
73+
torch.Tensor,
74+
"numpy",
75+
side_effect=TypeError("direct conversion is unavailable"),
76+
):
77+
converted = xp_asarray_nodetach(numpy_namespace, tensor)
78+
79+
self.assertIsInstance(converted, np.ndarray)
80+
np.testing.assert_allclose(converted, np.array([1.0, 2.0]))
81+
82+
@unittest.skipUnless(INSTALLED_PT, "PyTorch is not installed")
83+
def test_native_tensor_keeps_its_autograd_graph(self) -> None:
84+
tensor = torch.nn.Parameter(
85+
torch.tensor([1.0, 2.0], dtype=torch.float64, device=DEVICE)
86+
)
87+
torch_namespace = array_api_compat.array_namespace(tensor)
88+
89+
converted = xp_asarray_nodetach(torch_namespace, tensor)
90+
91+
self.assertIs(converted, tensor)
92+
self.assertTrue(converted.requires_grad)
93+
5994

6095
class TestXpMaximumAtConsistent(unittest.TestCase):
6196
"""Test maximum-at identities that differ between backend primitives."""

source/tests/pt_expt/descriptor/test_dpa4.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,30 @@ def test_consistency(self, use_env_seed, use_mapping) -> None:
107107
err_msg=err_msg,
108108
)
109109

110+
def test_default_charge_spin_uses_model_namespace(self) -> None:
111+
"""A device buffer supplies the default without a NumPy round trip."""
112+
dtype = PRECISION_DICT["float64"]
113+
descriptor = make_descriptor(
114+
self.nt,
115+
self.sel_mix,
116+
self.rcut,
117+
add_chg_spin_ebd=True,
118+
default_chg_spin=[0.5, -0.5],
119+
).to(self.device)
120+
coord_ext = torch.tensor(self.coord_ext, dtype=dtype, device=self.device)
121+
atype_ext = torch.tensor(self.atype_ext, dtype=int, device=self.device)
122+
nlist = torch.tensor(self.nlist, dtype=int, device=self.device)
123+
124+
with mock.patch.object(
125+
torch.Tensor,
126+
"numpy",
127+
side_effect=TypeError("direct conversion is unavailable"),
128+
):
129+
output = descriptor(coord_ext, atype_ext, nlist)[0]
130+
131+
assert output.device == self.device
132+
assert torch.isfinite(output).all()
133+
110134
def test_train_and_eval_amp_switches_are_independent(self) -> None:
111135
"""Training follows ``use_amp``, evaluation follows ``DP_AMP_INFER``.
112136

source/tests/pt_expt/utils/test_exclusion_mask.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
22
import unittest
3+
from unittest.mock import (
4+
patch,
5+
)
36

47
import numpy as np
58
import torch
@@ -38,6 +41,13 @@ def test_build_type_exclude_mask(self) -> None:
3841
des = AtomExcludeMask(nt, exclude_types=exclude_types)
3942
mask = des.build_type_exclude_mask(torch.as_tensor(atype, device=env.DEVICE))
4043
np.testing.assert_equal(mask.detach().cpu().numpy(), expected_mask)
44+
with patch.object(
45+
torch.Tensor,
46+
"numpy",
47+
side_effect=TypeError("direct conversion is unavailable"),
48+
):
49+
numpy_mask = des.build_type_exclude_mask(atype)
50+
np.testing.assert_equal(numpy_mask, expected_mask)
4151

4252
def test_type_mask_is_buffer(self) -> None:
4353
des = AtomExcludeMask(3, exclude_types=[0])
@@ -66,6 +76,29 @@ def test_build_type_exclude_mask(self) -> None:
6676
torch.as_tensor(self.atype_ext, device=env.DEVICE),
6777
)
6878
np.testing.assert_equal(mask.detach().cpu().numpy(), expected_mask)
79+
with patch.object(
80+
torch.Tensor,
81+
"numpy",
82+
side_effect=TypeError("direct conversion is unavailable"),
83+
):
84+
numpy_mask = des.build_type_exclude_mask(self.nlist, self.atype_ext)
85+
np.testing.assert_equal(
86+
numpy_mask,
87+
expected_mask,
88+
)
89+
90+
def test_build_edge_exclude_mask_with_numpy_inputs(self) -> None:
91+
des = PairExcludeMask(self.nt, exclude_types=[[0, 1]])
92+
edge_index = np.array([[0, 1, 2, 3], [1, 0, 3, 2]], dtype=np.int64)
93+
atype = np.array([0, 1, 0, 0], dtype=np.int32)
94+
95+
with patch.object(
96+
torch.Tensor,
97+
"numpy",
98+
side_effect=TypeError("direct conversion is unavailable"),
99+
):
100+
numpy_mask = des.build_edge_exclude_mask(edge_index, atype)
101+
np.testing.assert_equal(numpy_mask, np.array([0, 0, 1, 1], dtype=np.int32))
69102

70103
def test_type_mask_is_buffer(self) -> None:
71104
des = PairExcludeMask(self.nt, exclude_types=[[0, 1]])

0 commit comments

Comments
 (0)