Skip to content

Commit e582360

Browse files
authored
fix(jax): restore jax2tf savedmodel export (deepmodeling#5613)
## Summary - restore the JAX `.savedmodel` conversion path to use `jax2tf.convert(...)` instead of the TF2 SavedModel exporter - keep the TF2 eager exporter on the `.savedmodeltf` suffix - restore the graph-safe TensorFlow helper code used around the jax2tf-converted model body and document why these helpers should not be collapsed into TF2/ndtensorflow shims ## Background `source/tests/infer/convert-models.sh` documents `.savedmodel` as the JAX/JAX2TF output suffix and `.savedmodeltf` as the TF2 output suffix. After deepmodeling#5598, the JAX `.savedmodel` branch delegated to the TF2 exporter, so freshly exported `.savedmodel` and `.savedmodeltf` artifacts had the same ordinary TF op structure and no `XlaCallModule` nodes. This restores the historical contract: `.savedmodel` is a JAX/jax2tf artifact and should contain `XlaCallModule`; `.savedmodeltf` remains the TF2 eager SavedModel artifact. ## TF2 JIT note I also checked why `DP_JIT=1` on the TF2 exporter does not create `XlaCallModule` nodes. A minimal `tf.function(jit_compile=True)` SavedModel in TF 2.21 stores `_XlaMustCompile: true` on the FunctionDef/PartitionedCall, but the serialized graph still contains ordinary TF ops and no `XlaCallModule`. In contrast, a minimal `jax2tf.convert(...)` SavedModel serializes `XlaCallModule` nodes. So `XlaCallModule` is a marker for the jax2tf native serialization path, not for generic TF2 `jit_compile=True`. ## Validation - `dp convert-backend source/tests/infer/deeppot_sea.yaml /tmp/.../deeppot_sea.savedmodel`, parsed `saved_model.pb`: 8 `XlaCallModule` ops - `dp convert-backend source/tests/infer/deeppot_dpa.yaml /tmp/.../deeppot_dpa.savedmodel`, parsed `saved_model.pb`: 8 `XlaCallModule` ops - `dp convert-backend source/tests/infer/deeppot_sea.yaml /tmp/.../deeppot_sea.savedmodeltf`, parsed `saved_model.pb`: 0 XLA op names, as expected for TF2 eager export - `ruff format .` - `ruff check .` Please review, @wanghan-iapcm. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced JAX-based SavedModel export with additional execution endpoints and metadata queries (including neighbor-list/selection and output configuration). * Added in-graph coordinate transforms and improved neighbor-list/periodic ghost handling for export. * **Bug Fixes** * Corrected neighbor-list padding/truncation, cutoff masking, and stable type-distinguished ordering. * Improved handling of virtual atoms and empty-cell periodic extension cases. * **Tests** * Migrated neighbor-list/region tests to pure TensorFlow ops. * Added coverage for model call behavior and SavedModel export contents. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent b22a3c3 commit e582360

11 files changed

Lines changed: 966 additions & 168 deletions

File tree

deepmd/jax/jax2tf/format_nlist.py

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,88 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2-
"""Compatibility wrappers for TensorFlow neighbor-list formatting."""
2+
"""TensorFlow graph helpers for JAX/jax2tf SavedModel export.
33
4-
from typing import (
5-
Any,
6-
)
4+
This module is not a generic TF2 compatibility wrapper. The functions here are
5+
traced while saving the JAX ``.savedmodel`` artifact, before control reaches
6+
``jax2tf.convert``. Keep the implementation in plain TensorFlow ops so
7+
AutoGraph can see the tensor-dependent branches and emit graph control flow.
8+
Routing through ndtensorflow/dpmodel helpers can leave symbolic shape
9+
comparisons as Python ``if`` statements during SavedModel tracing.
10+
"""
711

812
import tensorflow as tf
913

10-
from deepmd.tf2.common import (
11-
to_tf_tensor,
12-
)
13-
from deepmd.tf2.utils._dpmodel import format_nlist as tf2_format_nlist
1414

15-
__all__ = ["format_nlist"]
15+
def _mask_out_of_cutoff(
16+
extended_coord: tf.Tensor,
17+
nlist: tf.Tensor,
18+
rcut: float,
19+
) -> tf.Tensor:
20+
nlist_shape = tf.shape(nlist)
21+
n_nf, n_nloc, n_nsel = nlist_shape[0], nlist_shape[1], nlist_shape[2]
22+
m_real_nei = nlist >= 0
23+
real_nlist = tf.where(m_real_nei, nlist, tf.zeros_like(nlist))
24+
coord0 = extended_coord[:, :n_nloc, :]
25+
index = tf.reshape(real_nlist, [n_nf, n_nloc * n_nsel])
26+
coord1 = tf.gather(extended_coord, index, batch_dims=1)
27+
coord1 = tf.reshape(coord1, [n_nf, n_nloc, n_nsel, 3])
28+
rr2 = tf.reduce_sum(tf.square(coord0[:, :, None, :] - coord1), axis=-1)
29+
return tf.where(
30+
tf.logical_and(m_real_nei, rr2 > tf.cast(rcut * rcut, rr2.dtype)),
31+
tf.fill(tf.shape(nlist), tf.cast(-1, nlist.dtype)),
32+
nlist,
33+
)
1634

1735

36+
@tf.function(autograph=True)
1837
def format_nlist(
19-
extended_coord: Any,
20-
nlist: Any,
38+
extended_coord: tf.Tensor,
39+
nlist: tf.Tensor,
2140
nsel: int,
2241
rcut: float,
2342
) -> tf.Tensor:
24-
return to_tf_tensor(tf2_format_nlist(extended_coord, nlist, nsel, rcut))
43+
"""Format neighbor list.
44+
45+
If nnei == nsel, do nothing;
46+
If nnei < nsel, pad -1;
47+
If nnei > nsel, sort by distance and truncate.
48+
"""
49+
nlist_shape = tf.shape(nlist)
50+
n_nf, n_nloc, n_nsel = nlist_shape[0], nlist_shape[1], nlist_shape[2]
51+
extended_coord = tf.reshape(extended_coord, [n_nf, -1, 3])
52+
53+
if n_nsel < nsel:
54+
ret = tf.concat(
55+
[
56+
nlist,
57+
tf.fill([n_nf, n_nloc, nsel - n_nsel], tf.cast(-1, nlist.dtype)),
58+
],
59+
axis=-1,
60+
)
61+
ret = _mask_out_of_cutoff(extended_coord, ret, rcut)
62+
elif n_nsel > nsel:
63+
m_real_nei = nlist >= 0
64+
ret = tf.where(m_real_nei, nlist, tf.zeros_like(nlist))
65+
coord0 = extended_coord[:, :n_nloc, :]
66+
index = tf.reshape(ret, [n_nf, n_nloc * n_nsel])
67+
coord1 = tf.gather(extended_coord, index, batch_dims=1)
68+
coord1 = tf.reshape(coord1, [n_nf, n_nloc, n_nsel, 3])
69+
rr2 = tf.reduce_sum(tf.square(coord0[:, :, None, :] - coord1), axis=-1)
70+
rr2 = tf.where(
71+
m_real_nei,
72+
rr2,
73+
tf.fill(tf.shape(rr2), tf.constant(float("inf"), rr2.dtype)),
74+
)
75+
ret_mapping = tf.argsort(rr2, axis=-1)
76+
rr2 = tf.sort(rr2, axis=-1)
77+
ret = tf.gather(ret, ret_mapping, batch_dims=2)
78+
ret = tf.where(
79+
rr2 > rcut * rcut,
80+
tf.fill(tf.shape(ret), tf.cast(-1, ret.dtype)),
81+
ret,
82+
)
83+
ret = ret[..., :nsel]
84+
else:
85+
ret = _mask_out_of_cutoff(extended_coord, nlist, rcut)
86+
# Reshape anyway; this tells XLA the shape without dynamic shape.
87+
ret = tf.reshape(ret, [n_nf, n_nloc, nsel])
88+
return ret

deepmd/jax/jax2tf/make_model.py

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,47 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2-
"""Compatibility wrappers for TensorFlow model-call helpers."""
2+
"""Outer TensorFlow call wrapper for the JAX/jax2tf SavedModel.
3+
4+
The wrapper builds PBC ghosts, neighbor lists, and output communication around
5+
the lower JAX model. It deliberately uses the graph-safe helpers in this
6+
package instead of the TF2 eager helpers, because this code is traced by
7+
``tf.saved_model.save`` and must keep tensor-shape branches convertible by
8+
AutoGraph before it invokes the jax2tf-converted model body.
9+
"""
310

411
from collections.abc import (
512
Callable,
613
)
7-
from typing import (
8-
Any,
9-
)
1014

1115
import tensorflow as tf
1216

1317
from deepmd.dpmodel.output_def import (
1418
ModelOutputDef,
1519
)
16-
from deepmd.tf2.common import (
17-
to_tf_tensor,
18-
unwrap_value,
19-
wrap_value,
20+
from deepmd.jax.jax2tf.nlist import (
21+
build_neighbor_list,
22+
extend_coord_with_ghosts,
2023
)
21-
from deepmd.tf2.make_model import (
22-
model_call_from_call_lower as tf2_model_call_from_call_lower,
24+
from deepmd.jax.jax2tf.region import (
25+
normalize_coord,
26+
)
27+
from deepmd.jax.jax2tf.transform_output import (
28+
communicate_extended_output,
2329
)
24-
25-
__all__ = ["model_call_from_call_lower"]
26-
27-
28-
def _wrap_call_lower(call_lower: Callable[..., dict[str, Any]]) -> Callable:
29-
def wrapped_call_lower(
30-
extended_coord: Any,
31-
extended_atype: Any,
32-
nlist: Any,
33-
mapping: Any,
34-
**kwargs: Any,
35-
) -> dict[str, Any]:
36-
return wrap_value(
37-
call_lower(
38-
to_tf_tensor(extended_coord),
39-
to_tf_tensor(extended_atype),
40-
to_tf_tensor(nlist),
41-
to_tf_tensor(mapping),
42-
**{kk: to_tf_tensor(vv) for kk, vv in kwargs.items()},
43-
)
44-
)
45-
46-
return wrapped_call_lower
4730

4831

4932
def model_call_from_call_lower(
5033
*, # enforce keyword-only arguments
51-
call_lower: Callable[..., dict[str, Any]],
34+
call_lower: Callable[
35+
[
36+
tf.Tensor,
37+
tf.Tensor,
38+
tf.Tensor,
39+
tf.Tensor,
40+
tf.Tensor,
41+
bool,
42+
],
43+
dict[str, tf.Tensor],
44+
],
5245
rcut: float,
5346
sel: list[int],
5447
mixed_types: bool,
@@ -60,18 +53,44 @@ def model_call_from_call_lower(
6053
aparam: tf.Tensor,
6154
do_atomic_virial: bool = False,
6255
) -> dict[str, tf.Tensor]:
63-
return unwrap_value(
64-
tf2_model_call_from_call_lower(
65-
call_lower=_wrap_call_lower(call_lower),
66-
rcut=rcut,
67-
sel=sel,
68-
mixed_types=mixed_types,
69-
model_output_def=model_output_def,
70-
coord=coord,
71-
atype=atype,
72-
box=box,
73-
fparam=fparam,
74-
aparam=aparam,
75-
do_atomic_virial=do_atomic_virial,
56+
"""Return model prediction from lower interface."""
57+
atype_shape = tf.shape(atype)
58+
nframes, nloc = atype_shape[0], atype_shape[1]
59+
cc, bb, fp, ap = coord, box, fparam, aparam
60+
del coord, box, fparam, aparam
61+
if tf.shape(bb)[-1] != 0:
62+
coord_normalized = normalize_coord(
63+
tf.reshape(cc, [nframes, nloc, 3]),
64+
tf.reshape(bb, [nframes, 3, 3]),
7665
)
66+
else:
67+
coord_normalized = cc
68+
extended_coord, extended_atype, mapping = extend_coord_with_ghosts(
69+
coord_normalized, atype, bb, rcut
70+
)
71+
nlist = build_neighbor_list(
72+
extended_coord,
73+
extended_atype,
74+
nloc,
75+
rcut,
76+
sel,
77+
# types will be distinguished in the lower interface, so it doesn't
78+
# need to be distinguished here
79+
distinguish_types=False,
80+
)
81+
extended_coord = tf.reshape(extended_coord, [nframes, -1, 3])
82+
model_predict_lower = call_lower(
83+
extended_coord,
84+
extended_atype,
85+
nlist,
86+
mapping,
87+
fparam=fp,
88+
aparam=ap,
89+
)
90+
model_predict = communicate_extended_output(
91+
model_predict_lower,
92+
model_output_def,
93+
mapping,
94+
do_atomic_virial=do_atomic_virial,
7795
)
96+
return model_predict

0 commit comments

Comments
 (0)