Skip to content

Commit 85c4efb

Browse files
committed
Merge remote-tracking branch 'origin/master' into fix/pr5978-maintainer-review-20260902
2 parents e93ba33 + be1a636 commit 85c4efb

19 files changed

Lines changed: 735 additions & 45 deletions

File tree

deepmd/backend/backend.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ class Feature(Flag):
153153
"""The supported suffixes of the saved model.
154154
155155
The first element is considered as the default suffix."""
156+
preserves_lower_input_kind: ClassVar[bool] = False
157+
"""Whether the IO hook preserves lower-ABI metadata without materializing it.
158+
159+
Schema-neutral model containers retain ``lower_input_kind`` as provenance
160+
even though their deserializer does not accept a concrete ``lower_kind``.
161+
Executable backends instead materialize a lower ABI and must expose that
162+
choice through their deserializer signature.
163+
"""
156164

157165
@abstractmethod
158166
def is_available(self) -> bool:

deepmd/backend/dpmodel.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ class DPModelBackend(Backend):
4242
"""The features of the backend."""
4343
suffixes: ClassVar[list[str]] = [".dp", ".yaml", ".yml"]
4444
"""The suffixes of the backend."""
45+
preserves_lower_input_kind: ClassVar[bool] = True
46+
"""DPModel files retain lower provenance without binding an execution ABI."""
4547

4648
def is_available(self) -> bool:
4749
"""Check if the backend is available.
@@ -106,10 +108,10 @@ def serialize_hook(self) -> Callable[[str], dict]:
106108
The serialize hook of the backend.
107109
"""
108110
from deepmd.dpmodel.utils.serialization import (
109-
load_dp_model,
111+
serialize_from_file,
110112
)
111113

112-
return load_dp_model
114+
return serialize_from_file
113115

114116
@property
115117
def deserialize_hook(self) -> Callable[[str, dict], None]:

deepmd/dpmodel/utils/serialization.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,29 @@ def convert_numpy_ndarray(x: Any) -> Any:
199199
return model_dict
200200

201201

202+
def serialize_from_file(filename: str) -> dict:
203+
"""Serialize a DPModel container for backend conversion.
204+
205+
DPModel files store model parameters rather than an executable lower ABI.
206+
Concrete provenance written by an earlier conversion is retained; a native
207+
file without provenance reports ``"auto"`` so the executable target selects
208+
a compatible lower from the model capabilities.
209+
210+
Parameters
211+
----------
212+
filename : str
213+
The DPModel filename.
214+
215+
Returns
216+
-------
217+
dict
218+
The serialized model data with declared lower-input semantics.
219+
"""
220+
model_dict = load_dp_model(filename)
221+
model_dict.setdefault("lower_input_kind", "auto")
222+
return model_dict
223+
224+
202225
def format_big_number(x: int) -> str:
203226
"""Format a big number with suffixes.
204227

deepmd/entrypoints/convert_backend.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ def convert_backend(
3030
If True, export .pt2/.pte models with per-atom virial correction.
3131
This adds ~2.5x inference cost. Default False. Silently ignored
3232
(with a warning) for backends that don't support the flag.
33+
34+
Notes
35+
-----
36+
Backend conversion preserves an explicit ``lower_input_kind`` reported by
37+
the source serializer. Sources without this metadata retain the target's
38+
automatic lower selection for backward compatibility. A target backend
39+
that cannot represent an explicit non-dense lower is rejected rather than
40+
silently changing the model function.
3341
"""
3442
inp_backend: Backend = Backend.detect_backend_by_model(INPUT)()
3543
out_backend: Backend = Backend.detect_backend_by_model(OUTPUT)()
@@ -40,8 +48,21 @@ def convert_backend(
4048

4149
sig = inspect.signature(out_hook)
4250
hook_kwargs: dict[str, Any] = {}
51+
lower_input_kind = data.get("lower_input_kind")
4352
if "lower_kind" in sig.parameters:
44-
hook_kwargs["lower_kind"] = "auto"
53+
hook_kwargs["lower_kind"] = (
54+
lower_input_kind if lower_input_kind is not None else "auto"
55+
)
56+
elif (
57+
lower_input_kind not in (None, "auto", "nlist")
58+
and not out_backend.preserves_lower_input_kind
59+
):
60+
raise ValueError(
61+
f"Cannot preserve lower_input_kind {lower_input_kind!r} when "
62+
f"converting to output backend {out_backend.name!r}: its "
63+
"deserializer does not accept a lower_kind. Retrain or freeze the "
64+
"model with that backend instead of converting this artifact."
65+
)
4566
if "do_atomic_virial" in sig.parameters:
4667
hook_kwargs["do_atomic_virial"] = atomic_virial
4768
elif atomic_virial:

deepmd/jax/utils/serialization.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ def restore_model(model_params: dict, model_state: dict) -> BaseModel:
427427
"jax_version": jax.__version__,
428428
"model": model_dict,
429429
"model_def_script": model_def_script,
430+
"lower_input_kind": "nlist",
430431
"@variables": {},
431432
}
432433
if min_nbor_dist is not None:
@@ -436,6 +437,7 @@ def restore_model(model_params: dict, model_state: dict) -> BaseModel:
436437
data = load_dp_model(model_file)
437438
data.pop("constants")
438439
data["@variables"].pop("stablehlo")
440+
data["lower_input_kind"] = "nlist"
439441
return data
440442
elif model_file.endswith(".savedmodel"):
441443
raise ValueError(

deepmd/pt/model/model/model.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
2626
"min_nbor_dist", torch.tensor(-1.0, dtype=torch.float64, device=env.DEVICE)
2727
)
2828

29+
def export_lower_input_kind(self) -> str:
30+
"""Return the lower-input ABI that preserves this model's semantics.
31+
32+
Returns
33+
-------
34+
str
35+
``"nlist"`` for the standard PyTorch model contract. Models with
36+
a graph-native deployment ABI override this method.
37+
"""
38+
return "nlist"
39+
2940
def compute_or_load_stat(
3041
self,
3142
sampled_func: Any,

deepmd/pt/model/model/spin_model.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,17 @@ def has_spin(self) -> bool:
454454
"""Returns whether it has spin input and output."""
455455
return True
456456

457+
def export_lower_input_kind(self) -> str:
458+
"""Return the dense ABI used by the virtual-atom spin model.
459+
460+
Returns
461+
-------
462+
str
463+
``"nlist"``, because virtual atoms are expanded inside the
464+
bounded neighbor-list contract.
465+
"""
466+
return "nlist"
467+
457468
@torch.jit.export
458469
def has_message_passing(self) -> bool:
459470
"""Returns whether the model has message passing."""

deepmd/pt/utils/serialization.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def serialize_from_file(model_file: str) -> dict:
5353
"pt_version": str(torch.__version__),
5454
"model": model_dict,
5555
"model_def_script": model_def_script,
56+
"lower_input_kind": model.export_lower_input_kind(),
5657
"@variables": {},
5758
}
5859
if model.get_min_nbor_dist() is not None:

deepmd/pt_expt/utils/serialization.py

Lines changed: 119 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@
5555
# ---------------------------------------------------------------------------
5656
PT2_EXTRA_PREFIX = "model/extra/"
5757

58+
# Backend conversion supplies the source artifact's lower ABI. Concrete target
59+
# schemas pass through unchanged. PT SeZM's ``edge_vec`` identifies an edge-list
60+
# source contract rather than a pt_expt schema; the target model capabilities
61+
# determine whether that contract is materialized as NeighborGraph or dense
62+
# nlist input.
63+
_LOWER_INPUT_KINDS = frozenset(
64+
{
65+
"nlist",
66+
"graph",
67+
"dpa1_canonical",
68+
"dpa4c_canonical",
69+
"edge_vec",
70+
}
71+
)
72+
5873

5974
def _strip_shape_assertions(graph_module: torch.nn.Module) -> None:
6075
"""Neutralise deferred shape-guard assertion nodes in an exported graph.
@@ -1284,7 +1299,8 @@ def serialize_from_file(model_file: str) -> dict:
12841299
dict
12851300
The serialized model data. If the archive contains
12861301
``model_def_script.json`` (training config), it is included
1287-
under the ``"model_def_script"`` key.
1302+
under the ``"model_def_script"`` key. ``lower_input_kind`` records
1303+
the concrete lower ABI from the artifact metadata.
12881304
"""
12891305
if model_file.endswith(".pt2"):
12901306
return _serialize_from_file_pt2(model_file)
@@ -1294,10 +1310,20 @@ def serialize_from_file(model_file: str) -> dict:
12941310

12951311
def _serialize_from_file_pte(model_file: str) -> dict:
12961312
"""Serialize a .pte model file to a dictionary."""
1297-
extra_files = {"model.json": "", "model_def_script.json": ""}
1313+
extra_files = {
1314+
"model.json": "",
1315+
"model_def_script.json": "",
1316+
"metadata.json": "",
1317+
}
12981318
torch.export.load(model_file, extra_files=extra_files)
12991319
model_dict = json.loads(extra_files["model.json"])
13001320
model_dict = _json_to_numpy(model_dict)
1321+
metadata = (
1322+
json.loads(extra_files["metadata.json"]) if extra_files["metadata.json"] else {}
1323+
)
1324+
model_dict["lower_input_kind"] = metadata.get(
1325+
"lower_input_kind", model_dict.get("lower_input_kind", "nlist")
1326+
)
13011327
if extra_files["model_def_script.json"]:
13021328
model_dict["model_def_script"] = json.loads(
13031329
extra_files["model_def_script.json"]
@@ -1315,6 +1341,7 @@ def _serialize_from_file_pt2(model_file: str) -> dict:
13151341

13161342
model_json_entry = PT2_EXTRA_PREFIX + "model.json"
13171343
model_def_script_entry = PT2_EXTRA_PREFIX + "model_def_script.json"
1344+
metadata_entry = PT2_EXTRA_PREFIX + "metadata.json"
13181345
with zipfile.ZipFile(model_file, "r") as zf:
13191346
names = zf.namelist()
13201347
if model_json_entry not in names:
@@ -1325,8 +1352,15 @@ def _serialize_from_file_pt2(model_file: str) -> dict:
13251352
model_def_script_json = ""
13261353
if model_def_script_entry in names:
13271354
model_def_script_json = zf.read(model_def_script_entry).decode("utf-8")
1355+
metadata_json = ""
1356+
if metadata_entry in names:
1357+
metadata_json = zf.read(metadata_entry).decode("utf-8")
13281358
model_dict = json.loads(model_json)
13291359
model_dict = _json_to_numpy(model_dict)
1360+
metadata = json.loads(metadata_json) if metadata_json else {}
1361+
model_dict["lower_input_kind"] = metadata.get(
1362+
"lower_input_kind", model_dict.get("lower_input_kind", "nlist")
1363+
)
13301364
if model_def_script_json:
13311365
model_dict["model_def_script"] = json.loads(model_def_script_json)
13321366
return model_dict
@@ -1506,19 +1540,22 @@ def _dpa4_kernel_levels_for_target(
15061540
os.environ[name] = value
15071541

15081542

1509-
def _resolve_lower_kind(model_file: str, data: dict, lower_kind: str) -> str:
1510-
"""Resolve ``lower_kind="auto"`` to a concrete lower-forward schema.
1543+
def _select_graph_lower_kind(data: dict, *, allow_canonical: bool) -> str | None:
1544+
"""Select the graph schema supported by the target model.
15111545
1512-
``"auto"`` selects the graph lower for a graph-lower model whose graph
1513-
implementation is exportable to ``.pt2`` and the dense nlist lower for
1514-
everything else. Eligible compressed DPA1 and DPA4C energy models select
1515-
their compact canonical graph schemas. Any explicit lower kind is returned
1516-
unchanged.
1546+
Parameters
1547+
----------
1548+
data : dict
1549+
Serialized model data.
1550+
allow_canonical : bool
1551+
Whether an eligible compact canonical schema may replace NeighborGraph.
1552+
1553+
Returns
1554+
-------
1555+
str or None
1556+
The supported graph schema, or ``None`` when the model uses the dense
1557+
lower.
15171558
"""
1518-
if lower_kind != "auto":
1519-
return lower_kind
1520-
if not model_file.endswith(".pt2") or data["model"].get("type") == "spin_ener":
1521-
return "nlist"
15221559
from deepmd.pt_expt.model.graph_lower import (
15231560
model_uses_graph_lower,
15241561
)
@@ -1527,7 +1564,9 @@ def _resolve_lower_kind(model_file: str, data: dict, lower_kind: str) -> str:
15271564
)
15281565

15291566
model = BaseModel.deserialize(data["model"])
1530-
if model_uses_graph_lower(model) and _supports_graph_export(model):
1567+
if not (model_uses_graph_lower(model) and _supports_graph_export(model)):
1568+
return None
1569+
if allow_canonical:
15311570
from deepmd.pt_expt.kernels.cuda.dpa1.canonical import (
15321571
canonical_model_eligible as dpa1_canonical_eligible,
15331572
)
@@ -1539,8 +1578,62 @@ def _resolve_lower_kind(model_file: str, data: dict, lower_kind: str) -> str:
15391578
return "dpa4c_canonical"
15401579
if dpa1_canonical_eligible(model):
15411580
return "dpa1_canonical"
1542-
return "graph"
1543-
return "nlist"
1581+
return "graph"
1582+
1583+
1584+
def _resolve_lower_kind(model_file: str, data: dict, lower_kind: str) -> str:
1585+
"""Resolve ``lower_kind="auto"`` to a concrete lower-forward schema.
1586+
1587+
``"auto"`` selects the graph lower for a graph-lower model whose graph
1588+
implementation is exportable to ``.pt2`` and the dense nlist lower for
1589+
everything else. Eligible compressed DPA1 and DPA4C energy models select
1590+
their compact canonical graph schemas. Any explicit lower kind is returned
1591+
unchanged.
1592+
"""
1593+
if lower_kind != "auto":
1594+
return lower_kind
1595+
if not model_file.endswith(".pt2") or data["model"].get("type") == "spin_ener":
1596+
return "nlist"
1597+
return _select_graph_lower_kind(data, allow_canonical=True) or "nlist"
1598+
1599+
1600+
def _resolve_target_lower_kind(model_file: str, data: dict, lower_kind: str) -> str:
1601+
"""Resolve a source lower ABI to a concrete pt_expt export schema."""
1602+
source_lower_kind = _resolve_lower_kind(model_file, data, lower_kind)
1603+
if source_lower_kind not in _LOWER_INPUT_KINDS:
1604+
raise ValueError(
1605+
f"Unsupported lower_kind {source_lower_kind!r}; expected one of "
1606+
f"{sorted(_LOWER_INPUT_KINDS)}."
1607+
)
1608+
target_lower_kind = source_lower_kind
1609+
if source_lower_kind == "edge_vec":
1610+
target_lower_kind = (
1611+
_select_graph_lower_kind(data, allow_canonical=False) or "nlist"
1612+
)
1613+
1614+
if data["model"].get("type") == "native_spin" and target_lower_kind not in (
1615+
"graph",
1616+
"dpa4c_canonical",
1617+
):
1618+
if lower_kind == "auto":
1619+
if not model_file.endswith(".pt2"):
1620+
raise ValueError(
1621+
"automatic lower selection for native-spin models requires "
1622+
"a .pt2 output because native-spin models do not implement "
1623+
"the dense nlist lower"
1624+
)
1625+
raise ValueError(
1626+
"automatic lower selection found no exportable graph lower for "
1627+
"this native-spin model, which does not implement the dense "
1628+
"nlist lower"
1629+
)
1630+
raise ValueError(
1631+
"native-spin models implement only the NeighborGraph and compact "
1632+
f"canonical lowers (got lower_kind={target_lower_kind!r}); use "
1633+
"lower_kind='graph', or lower_kind='dpa4c_canonical' for an "
1634+
"eligible compressed DPA4C model, with a .pt2 output."
1635+
)
1636+
return target_lower_kind
15441637

15451638

15461639
def deserialize_to_file(
@@ -1581,34 +1674,19 @@ def deserialize_to_file(
15811674
(``atype``/``n_node``/``edge_index``/``edge_vec``/``edge_mask`` and
15821675
the destination/source CSR views) with a DYNAMIC edge axis ``E``
15831676
(``Dim("nedge", min=2)``), so the artifact accepts any system size.
1584-
``"auto"`` (used by ``convert-backend``) resolves to ``"graph"`` for an
1585-
exportable graph-lower ``.pt2`` and ``"nlist"`` otherwise (see
1586-
:func:`_resolve_lower_kind`). A graph lower preserves the selected
1587-
inference operators and always includes the per-atom virial. DPA1 and
1588-
DPA4C graph pipelines use ``DP_CUDA_INFER >= 2``; DPA4 ``.pt2`` follows
1589-
its PT freeze defaults unless the environment explicitly selects other
1590-
levels.
1677+
``"auto"`` resolves to ``"graph"`` for an exportable graph-lower
1678+
``.pt2`` and ``"nlist"`` otherwise (see :func:`_resolve_lower_kind`).
1679+
Backend conversion passes the source artifact's concrete lower kind;
1680+
compatible source ABIs are mapped to the target's native schema while
1681+
preserving their execution semantics. A graph lower preserves the
1682+
selected inference operators and always includes the per-atom virial.
1683+
DPA1 and DPA4C graph pipelines use ``DP_CUDA_INFER >= 2``; DPA4
1684+
``.pt2`` follows its PT freeze defaults unless the environment
1685+
explicitly selects other levels.
15911686
The selected schema is recorded as ``lower_input_kind`` in
15921687
``metadata.json``.
15931688
"""
1594-
lower_kind = _resolve_lower_kind(model_file, data, lower_kind)
1595-
if data["model"].get("type") == "native_spin" and lower_kind not in (
1596-
"graph",
1597-
"dpa4c_canonical",
1598-
):
1599-
# Native-spin models implement the NeighborGraph lower and, for an
1600-
# eligible compressed DPA4C, the compact canonical one; the dense/nlist
1601-
# trace branch does not exist for them. The public freeze layer
1602-
# resolves this before calling here (see
1603-
# deepmd.pt_expt.entrypoints.main.freeze); this guard pins the
1604-
# contract for direct programmatic callers with a clear error instead
1605-
# of an opaque trace-time failure.
1606-
raise ValueError(
1607-
"native-spin models implement only the NeighborGraph and compact "
1608-
f"canonical lowers (got lower_kind={lower_kind!r}); use "
1609-
"lower_kind='graph', or lower_kind='dpa4c_canonical' for an "
1610-
"eligible compressed DPA4C model, with a .pt2 output."
1611-
)
1689+
lower_kind = _resolve_target_lower_kind(model_file, data, lower_kind)
16121690
uses_dpa4_defaults = model_file.endswith(".pt2") and _uses_dpa4_kernel_defaults(
16131691
data["model"]
16141692
)

0 commit comments

Comments
 (0)