Skip to content

Commit 286f12d

Browse files
njzjznjzjz-bot
andauthored
fix(dpmodel): keep output reductions on active backend (#5789)
## Summary - translate the configured NumPy energy precision into the dtype object of the active array namespace; - use namespace-native casts and reductions in the dense dpmodel output transform; - apply the same dtype correction to the ragged graph transform, which otherwise passes a NumPy dtype class to Torch; - add focused Torch regressions for extensive, masked-intensive, and graph reductions. ## Why existing tests missed this There were no direct tests exercising the backend-agnostic dpmodel transform helpers with Torch arrays. Normal PyTorch and Paddle model tests call their backend-specific transform implementations instead, while the existing dpmodel graph tests use NumPy. As a result, neither Tensor.astype nor the NumPy dtype passed into the Torch namespace was reached. The new tests invoke the generic dpmodel helpers directly and verify that results remain Torch tensors in the configured energy precision. ## Validation - new transform regressions plus existing ragged graph tests: 10 passed; - additional reviewer probes passed for NumPy, Torch, JAX, and array-api-strict; - ruff format .; - ruff check .; - git diff --check. Closes #5640. Coding agent: Codex Codex version: codex-cli 0.144.1 Model: gpt-5.6-sol Reasoning effort: xhigh <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved energy reduction accuracy by using backend-specific precision for accumulation and per-frame/per-node division, instead of a fixed global dtype. * Ensured intensive/extensive reductions consistently cast reducible values—and any provided masks or computed divisors—to the correct backend dtype before summing/averaging. * **Tests** * Added Torch backend-preservation tests for both dense and graph reduction paths, covering intensive vs extensive behavior with and without masks and validating dtype promotion and correctness. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: njzjz-bot <njzjz-bot@users.noreply.github.com> Co-authored-by: njzjz-bot <njzjz.bot@gmail.com>
1 parent 89d04bd commit 286f12d

3 files changed

Lines changed: 166 additions & 11 deletions

File tree

deepmd/dpmodel/model/edge_transform_output.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
from deepmd.dpmodel.common import (
2323
GLOBAL_ENER_FLOAT_PRECISION,
24+
RESERVED_PRECISION_DICT,
25+
get_xp_precision,
2426
)
2527
from deepmd.dpmodel.output_def import (
2628
get_deriv_name,
@@ -121,6 +123,11 @@ def fit_output_to_model_output_graph(
121123

122124
n_node = graph.n_node
123125
xp = array_api_compat.get_namespace(n_node)
126+
# The configured energy precision is represented by a NumPy dtype class,
127+
# which is not accepted as a dtype by every array namespace (notably Torch).
128+
energy_dtype = get_xp_precision(
129+
xp, RESERVED_PRECISION_DICT[GLOBAL_ENER_FLOAT_PRECISION]
130+
)
124131
nf = n_node.shape[0]
125132
frame_id = frame_id_from_n_node(n_node)
126133
n_total = next(iter(fit_ret.values())).shape[0]
@@ -133,21 +140,21 @@ def fit_output_to_model_output_graph(
133140
if not vdef.reducible:
134141
continue
135142
kk_redu = get_reduce_name(kk)
136-
vv_e = xp.astype(vv, GLOBAL_ENER_FLOAT_PRECISION)
143+
vv_e = xp.astype(vv, energy_dtype)
137144
if owned is not None:
138-
owned_e = xp.astype(owned, GLOBAL_ENER_FLOAT_PRECISION)
145+
owned_e = xp.astype(owned, energy_dtype)
139146
vv_e = vv_e * xp.reshape(owned_e, (n_total, *([1] * (vv_e.ndim - 1))))
140147
redu = segment_sum(vv_e, frame_id, nf) # (nf, *shape)
141148
if vdef.intensive:
142149
if mask is not None:
143-
cnt_mask = xp.astype(mask, GLOBAL_ENER_FLOAT_PRECISION)
150+
cnt_mask = xp.astype(mask, energy_dtype)
144151
if owned is not None:
145152
cnt_mask = cnt_mask * owned_e
146153
cnt = segment_sum(cnt_mask, frame_id, nf)
147154
elif owned is not None:
148155
cnt = segment_sum(owned_e, frame_id, nf)
149156
else:
150-
cnt = xp.astype(n_node, GLOBAL_ENER_FLOAT_PRECISION)
157+
cnt = xp.astype(n_node, energy_dtype)
151158
redu = redu / xp.reshape(cnt, (nf, *([1] * (redu.ndim - 1))))
152159
model_ret[kk_redu] = redu
153160
if vdef.r_differentiable:

deepmd/dpmodel/model/transform_output.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33

44
import array_api_compat
5-
import numpy as np
65

76
from deepmd.dpmodel.array_api import (
87
Array,
98
xp_scatter_sum,
109
)
1110
from deepmd.dpmodel.common import (
1211
GLOBAL_ENER_FLOAT_PRECISION,
12+
RESERVED_PRECISION_DICT,
13+
get_xp_precision,
1314
)
1415
from deepmd.dpmodel.output_def import (
1516
FittingOutputDef,
@@ -34,6 +35,12 @@ def fit_output_to_model_output(
3435
3536
"""
3637
xp = array_api_compat.get_namespace(coord_ext)
38+
# GLOBAL_ENER_FLOAT_PRECISION is a NumPy dtype class. Array namespaces such
39+
# as Torch require their own dtype object even when the precision name is the
40+
# same, so resolve it before casting any backend array.
41+
energy_dtype = get_xp_precision(
42+
xp, RESERVED_PRECISION_DICT[GLOBAL_ENER_FLOAT_PRECISION]
43+
)
3744
model_ret = dict(fit_ret.items())
3845
for kk, vv in fit_ret.items():
3946
vdef = fit_output_def[kk]
@@ -45,16 +52,14 @@ def fit_output_to_model_output(
4552
if vdef.intensive:
4653
if mask is not None:
4754
model_ret[kk_redu] = xp.sum(
48-
vv.astype(GLOBAL_ENER_FLOAT_PRECISION), axis=atom_axis
49-
) / np.sum(mask, axis=-1, keepdims=True)
55+
xp.astype(vv, energy_dtype), axis=atom_axis
56+
) / xp.sum(xp.astype(mask, energy_dtype), axis=-1, keepdims=True)
5057
else:
5158
model_ret[kk_redu] = xp.mean(
52-
vv.astype(GLOBAL_ENER_FLOAT_PRECISION), axis=atom_axis
59+
xp.astype(vv, energy_dtype), axis=atom_axis
5360
)
5461
else:
55-
model_ret[kk_redu] = xp.sum(
56-
vv.astype(GLOBAL_ENER_FLOAT_PRECISION), axis=atom_axis
57-
)
62+
model_ret[kk_redu] = xp.sum(xp.astype(vv, energy_dtype), axis=atom_axis)
5863
if vdef.r_differentiable:
5964
kk_derv_r, kk_derv_c = get_deriv_name(kk)
6065
# name-holders
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Backend-preservation tests for fitting-output reductions."""
3+
4+
import pytest
5+
6+
from deepmd.dpmodel.model.edge_transform_output import (
7+
fit_output_to_model_output_graph,
8+
)
9+
from deepmd.dpmodel.model.transform_output import (
10+
fit_output_to_model_output,
11+
)
12+
from deepmd.dpmodel.output_def import (
13+
FittingOutputDef,
14+
OutputVariableDef,
15+
)
16+
from deepmd.dpmodel.utils.neighbor_graph import (
17+
NeighborGraph,
18+
)
19+
20+
torch = pytest.importorskip("torch")
21+
22+
23+
def _output_def(*, intensive: bool) -> FittingOutputDef:
24+
"""Build the smallest reducible fitting definition used by these tests."""
25+
return FittingOutputDef(
26+
[
27+
OutputVariableDef(
28+
name="energy",
29+
shape=[1],
30+
reducible=True,
31+
r_differentiable=False,
32+
c_differentiable=False,
33+
intensive=intensive,
34+
)
35+
]
36+
)
37+
38+
39+
def test_dense_torch_extensive_reduction_stays_on_backend() -> None:
40+
"""Dense reduction must not rely on NumPy-style ``Tensor.astype``."""
41+
atomic = torch.tensor([[[1.0], [2.0], [3.0]]], dtype=torch.float32)
42+
coord = torch.zeros((1, 3, 3), dtype=torch.float32)
43+
44+
result = fit_output_to_model_output(
45+
{"energy": atomic}, _output_def(intensive=False), coord
46+
)["energy_redu"]
47+
48+
assert isinstance(result, torch.Tensor)
49+
assert result.dtype is torch.float64
50+
torch.testing.assert_close(result, torch.tensor([[6.0]], dtype=torch.float64))
51+
52+
53+
def test_dense_torch_mask_count_uses_energy_dtype() -> None:
54+
"""The intensive divisor must be reduced by Torch in energy precision."""
55+
atomic = torch.tensor([[[1.0], [2.0], [3.0]]], dtype=torch.float32)
56+
coord = torch.zeros((1, 3, 3), dtype=torch.float32)
57+
mask = torch.tensor([[True, True, False]])
58+
59+
result = fit_output_to_model_output(
60+
{"energy": atomic}, _output_def(intensive=True), coord, mask=mask
61+
)["energy_redu"]
62+
63+
assert isinstance(result, torch.Tensor)
64+
assert result.dtype is torch.float64
65+
torch.testing.assert_close(result, torch.tensor([[3.0]], dtype=torch.float64))
66+
67+
68+
def test_dense_torch_no_mask_intensive_reduction_stays_on_backend() -> None:
69+
"""Exercise the intensive reduction branch without a real-atom mask."""
70+
atomic = torch.tensor([[[1.0], [2.0], [4.0]]], dtype=torch.float32)
71+
coord = torch.zeros((1, 3, 3), dtype=torch.float32)
72+
73+
result = fit_output_to_model_output(
74+
{"energy": atomic}, _output_def(intensive=True), coord
75+
)["energy_redu"]
76+
77+
assert isinstance(result, torch.Tensor)
78+
assert result.dtype is torch.float64
79+
torch.testing.assert_close(result, torch.tensor([[7.0 / 3.0]], dtype=torch.float64))
80+
81+
82+
def test_graph_torch_mask_count_uses_backend_dtype() -> None:
83+
"""Graph reductions must translate the NumPy precision to ``torch.dtype``."""
84+
graph = NeighborGraph(
85+
n_node=torch.tensor([2, 1], dtype=torch.int64),
86+
edge_index=torch.empty((2, 0), dtype=torch.int64),
87+
edge_vec=torch.empty((0, 3), dtype=torch.float32),
88+
edge_mask=torch.empty((0,), dtype=torch.bool),
89+
)
90+
atomic = torch.tensor([[1.0], [2.0], [6.0]], dtype=torch.float32)
91+
mask = torch.tensor([True, True, True])
92+
93+
result = fit_output_to_model_output_graph(
94+
{"energy": atomic}, _output_def(intensive=True), graph, mask=mask
95+
)["energy_redu"]
96+
97+
assert isinstance(result, torch.Tensor)
98+
assert result.dtype is torch.float64
99+
torch.testing.assert_close(
100+
result, torch.tensor([[1.5], [6.0]], dtype=torch.float64)
101+
)
102+
103+
104+
def test_graph_torch_no_mask_intensive_reduction_stays_on_backend() -> None:
105+
"""Exercise the graph intensive fallback using per-frame node counts."""
106+
graph = NeighborGraph(
107+
n_node=torch.tensor([2, 1], dtype=torch.int64),
108+
edge_index=torch.empty((2, 0), dtype=torch.int64),
109+
edge_vec=torch.empty((0, 3), dtype=torch.float32),
110+
edge_mask=torch.empty((0,), dtype=torch.bool),
111+
)
112+
atomic = torch.tensor([[1.0], [3.0], [6.0]], dtype=torch.float32)
113+
114+
result = fit_output_to_model_output_graph(
115+
{"energy": atomic}, _output_def(intensive=True), graph
116+
)["energy_redu"]
117+
118+
assert isinstance(result, torch.Tensor)
119+
assert result.dtype is torch.float64
120+
torch.testing.assert_close(
121+
result, torch.tensor([[2.0], [6.0]], dtype=torch.float64)
122+
)
123+
124+
125+
def test_graph_torch_extensive_reduction_stays_on_backend() -> None:
126+
"""Exercise the graph extensive branch independently of intensive counts."""
127+
graph = NeighborGraph(
128+
n_node=torch.tensor([2, 1], dtype=torch.int64),
129+
edge_index=torch.empty((2, 0), dtype=torch.int64),
130+
edge_vec=torch.empty((0, 3), dtype=torch.float32),
131+
edge_mask=torch.empty((0,), dtype=torch.bool),
132+
)
133+
atomic = torch.tensor([[1.0], [3.0], [6.0]], dtype=torch.float32)
134+
135+
result = fit_output_to_model_output_graph(
136+
{"energy": atomic}, _output_def(intensive=False), graph
137+
)["energy_redu"]
138+
139+
assert isinstance(result, torch.Tensor)
140+
assert result.dtype is torch.float64
141+
torch.testing.assert_close(
142+
result, torch.tensor([[4.0], [6.0]], dtype=torch.float64)
143+
)

0 commit comments

Comments
 (0)