Skip to content

Commit 86b61b1

Browse files
author
njzjz-bot
committed
fix(tf): preserve ghost gradients in ProdForceGrad
Size upstream force gradients by all atoms and keep local and ghost slices distinct in the legacy TensorFlow ProdForce gradient. Validate atom counts and neighbor bounds before indexing. Cover a second ghost at index nloc + 1 so the regression exercises the removed modulo branch and proves its independent upstream gradient is used. Coding-Agent: Codex Codex-Version: codex-cli 0.144.4 Model: gpt-5.6-sol Reasoning-Effort: xhigh
1 parent 6c3b985 commit 86b61b1

2 files changed

Lines changed: 83 additions & 6 deletions

File tree

source/op/tf/prod_force_grad.cc

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class ProdForceGradOp : public OpKernel {
6868

6969
int nframes = net_deriv_tensor.shape().dim_size(0);
7070
int nloc = natoms(0);
71+
int nall = natoms(1);
7172
int ndescrpt = nloc > 0 ? net_deriv_tensor.shape().dim_size(1) / nloc : 0;
7273
int nnei = nloc > 0 ? nlist_tensor.shape().dim_size(1) / nloc : 0;
7374

@@ -85,9 +86,14 @@ class ProdForceGradOp : public OpKernel {
8586
context, (nframes == axis_shape.dim_size(0)),
8687
deepmd::tf_compat::InvalidArgument("number of frames should match"));
8788

88-
OP_REQUIRES(context, (nloc * 3 == grad_shape.dim_size(1)),
89+
OP_REQUIRES(
90+
context, (nall >= nloc),
91+
deepmd::tf_compat::InvalidArgument(
92+
"number of all atoms should not be smaller than local atoms"));
93+
OP_REQUIRES(context,
94+
(static_cast<int64_t>(nall) * 3 == grad_shape.dim_size(1)),
8995
deepmd::tf_compat::InvalidArgument(
90-
"input grad shape should be 3 x natoms"));
96+
"input grad shape should be 3 x all atoms"));
9197
OP_REQUIRES(context,
9298
(static_cast<int64_t>(nloc) * ndescrpt * 12 ==
9399
in_deriv_shape.dim_size(1)),
@@ -118,10 +124,20 @@ class ProdForceGradOp : public OpKernel {
118124
auto axis = axis_tensor.flat<int>();
119125
auto grad_net = grad_net_tensor->flat<FPTYPE>();
120126

127+
// ProdForce returns one force vector for every local and ghost atom. Keep
128+
// those upstream gradients distinct: folding ghost indices modulo nloc
129+
// would differentiate a different output than the forward op produced.
130+
const int64_t nlist_size = static_cast<int64_t>(nframes) * nloc * nnei;
131+
for (int64_t ii = 0; ii < nlist_size; ++ii) {
132+
OP_REQUIRES(context, nlist(ii) < nall,
133+
deepmd::tf_compat::InvalidArgument(
134+
"neighbor index should be smaller than all atoms"));
135+
}
136+
121137
// loop over frames
122138
#pragma omp parallel for
123139
for (int kk = 0; kk < nframes; ++kk) {
124-
int grad_iter = kk * nloc * 3;
140+
int grad_iter = kk * nall * 3;
125141
int net_iter = kk * nloc * ndescrpt;
126142
int in_iter = kk * nloc * ndescrpt * 12;
127143
int nlist_iter = kk * nloc * nnei;
@@ -163,9 +179,6 @@ class ProdForceGradOp : public OpKernel {
163179
// loop over neighbors
164180
for (int jj = 0; jj < nnei; ++jj) {
165181
int j_idx = nlist(nlist_iter + i_idx * nnei + jj);
166-
if (j_idx > nloc) {
167-
j_idx = j_idx % nloc;
168-
}
169182
if (j_idx < 0) {
170183
continue;
171184
}

source/tests/tf/test_prod_force_grad.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,74 @@
44
from deepmd.tf.env import (
55
GLOBAL_TF_FLOAT_PRECISION,
66
op_grads_module,
7+
op_module,
78
tf,
89
)
910

1011

12+
class TestLegacyProdForceGradGhost(tf.test.TestCase):
13+
"""Exercise a non-boundary ghost through ProdForce's registered gradient."""
14+
15+
def test_second_ghost_uses_its_own_upstream_gradient(self) -> None:
16+
nloc = 1
17+
nall = 3
18+
nframes = 2
19+
ndescrpt = 4
20+
net_deriv = tf.placeholder(tf.float64, [nframes, nloc * ndescrpt])
21+
22+
in_deriv = np.zeros((nframes, nloc * ndescrpt * 12))
23+
# The only neighbor is axis 0. Give descriptor 0 a simple derivative
24+
# with respect to the ghost atom and leave every other term zero.
25+
in_deriv[:, 3:6] = [1.0, 2.0, 3.0]
26+
force = op_module.prod_force(
27+
net_deriv,
28+
tf.constant(in_deriv, dtype=tf.float64),
29+
# The second ghost has index nloc + 1. This specifically exercises
30+
# the removed ``j_idx > nloc`` modulo branch; the first ghost at
31+
# exactly nloc would never have entered that branch.
32+
tf.constant([[nloc + 1], [nloc + 1]], dtype=tf.int32),
33+
tf.constant([[0, 0, 0, 0], [0, 0, 0, 0]], dtype=tf.int32),
34+
tf.constant([nloc, nall, nloc], dtype=tf.int32),
35+
n_a_sel=1,
36+
n_r_sel=0,
37+
)
38+
39+
# Local and both ghost gradients deliberately differ. The expected
40+
# result must use the second-ghost slice, not fold index 2 onto local
41+
# index 0 as the old modulo branch did.
42+
upstream = tf.constant(
43+
[
44+
[10.0, 11.0, 12.0, 7.0, 8.0, 9.0, 4.0, 5.0, 6.0],
45+
[20.0, 21.0, 22.0, 8.0, 7.0, 6.0, 1.0, 2.0, 3.0],
46+
],
47+
dtype=tf.float64,
48+
)
49+
grad_net = tf.gradients(force, net_deriv, grad_ys=upstream)[0]
50+
51+
with self.cached_session() as sess:
52+
actual_force, actual_grad = sess.run(
53+
[force, grad_net],
54+
feed_dict={
55+
net_deriv: [
56+
[1.0, 0.0, 0.0, 0.0],
57+
[2.0, 0.0, 0.0, 0.0],
58+
]
59+
},
60+
)
61+
62+
np.testing.assert_array_equal(
63+
actual_force,
64+
[
65+
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, -2.0, -3.0],
66+
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -2.0, -4.0, -6.0],
67+
],
68+
)
69+
np.testing.assert_array_equal(
70+
actual_grad,
71+
[[-32.0, 0.0, 0.0, 0.0], [-14.0, 0.0, 0.0, 0.0]],
72+
)
73+
74+
1175
class TestProdForceGrad(tf.test.TestCase):
1276
def setUp(self) -> None:
1377
self.sess = self.cached_session().__enter__()

0 commit comments

Comments
 (0)