Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion deepmd/entrypoints/convert_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ def convert_backend(
If True, export .pt2/.pte models with per-atom virial correction.
This adds ~2.5x inference cost. Default False. Silently ignored
(with a warning) for backends that don't support the flag.

Notes
-----
Backend conversion preserves an explicit ``lower_input_kind`` reported by
the source serializer. Sources without this metadata retain the target's
automatic lower selection for backward compatibility. A target backend
that cannot represent an explicit non-dense lower is rejected rather than
silently changing the model function.
"""
inp_backend: Backend = Backend.detect_backend_by_model(INPUT)()
out_backend: Backend = Backend.detect_backend_by_model(OUTPUT)()
Expand All @@ -40,8 +48,18 @@ def convert_backend(

sig = inspect.signature(out_hook)
hook_kwargs: dict[str, Any] = {}
lower_input_kind = data.get("lower_input_kind")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ABI declaration is only half-rolled-out, so the result now depends on which file format the weights arrived in.

pt, tf, tf2 and jax all declare a kind, but deepmd/dpmodel/utils/serialization.py (save_dp_model/load_dp_model) and deepmd/pd/utils/serialization.py do not, so data.get(...) is None for a .dp source and this sends "auto". _resolve_lower_kind then picks graph/dpa1_canonical/dpa4c_canonical for an eligible model — where the very same weights arriving as a .pth would have been pinned to "nlist".

So the same model converts to a different artifact depending on whether it came through .dp or .pth, which is the class of surprise this PR is otherwise removing. doc/backend.md frames the no-metadata path as a legacy/backward-compatibility fallback, but .dp is not legacy — it is a current format that will simply never carry the field unless it is added.

Adding the declaration to the dpmodel serializer would close it. Paddle is less urgent, since its serialize_from_file raises NotImplementedError today, but it is the same gap.

Related, lower stakes: the rejection just below fires for any target whose hook lacks a lower_kind parameter, which includes .dp. Unlike .pb/.pth, a .dp bakes no execution schema at all — it is a weights container, and whoever re-exports it later picks their own lower. Blocking dp convert-backend sezm.pt out.dp therefore protects nothing while losing a working path. Worth narrowing the guard to targets that actually materialise a lower.

if "lower_kind" in sig.parameters:
hook_kwargs["lower_kind"] = "auto"
hook_kwargs["lower_kind"] = (
lower_input_kind if lower_input_kind is not None else "auto"
)
elif lower_input_kind not in (None, "nlist"):
raise ValueError(
f"Cannot preserve lower_input_kind {lower_input_kind!r} when "
f"converting to output backend {out_backend.name!r}: its "
"deserializer does not accept a lower_kind. Retrain or freeze the "
"model with that backend instead of converting this artifact."
)
if "do_atomic_virial" in sig.parameters:
hook_kwargs["do_atomic_virial"] = atomic_virial
elif atomic_virial:
Expand Down
2 changes: 2 additions & 0 deletions deepmd/jax/utils/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ def restore_model(model_params: dict, model_state: dict) -> BaseModel:
"jax_version": jax.__version__,
"model": model_dict,
"model_def_script": model_def_script,
"lower_input_kind": "nlist",
"@variables": {},
}
if min_nbor_dist is not None:
Expand All @@ -436,6 +437,7 @@ def restore_model(model_params: dict, model_state: dict) -> BaseModel:
data = load_dp_model(model_file)
data.pop("constants")
data["@variables"].pop("stablehlo")
data["lower_input_kind"] = "nlist"
return data
elif model_file.endswith(".savedmodel"):
raise ValueError(
Expand Down
11 changes: 11 additions & 0 deletions deepmd/pt/model/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
"min_nbor_dist", torch.tensor(-1.0, dtype=torch.float64, device=env.DEVICE)
)

def export_lower_input_kind(self) -> str:
"""Return the lower-input ABI that preserves this model's semantics.

Returns
-------
str
``"nlist"`` for the standard PyTorch model contract. Models with
a graph-native deployment ABI override this method.
"""
return "nlist"

def compute_or_load_stat(
self,
sampled_func: Any,
Expand Down
11 changes: 11 additions & 0 deletions deepmd/pt/model/model/spin_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,17 @@ def has_spin(self) -> bool:
"""Returns whether it has spin input and output."""
return True

def export_lower_input_kind(self) -> str:
"""Return the dense ABI used by the virtual-atom spin model.

Returns
-------
str
``"nlist"``, because virtual atoms are expanded inside the
bounded neighbor-list contract.
"""
return "nlist"

@torch.jit.export
def has_message_passing(self) -> bool:
"""Returns whether the model has message passing."""
Expand Down
1 change: 1 addition & 0 deletions deepmd/pt/utils/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def serialize_from_file(model_file: str) -> dict:
"pt_version": str(torch.__version__),
"model": model_dict,
"model_def_script": model_def_script,
"lower_input_kind": model.export_lower_input_kind(),
Comment thread
OutisLi marked this conversation as resolved.
"@variables": {},
}
if model.get_min_nbor_dist() is not None:
Expand Down
59 changes: 52 additions & 7 deletions deepmd/pt_expt/utils/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@
# ---------------------------------------------------------------------------
PT2_EXTRA_PREFIX = "model/extra/"

# Backend conversion supplies the source artifact's lower ABI. Each accepted
# source kind maps to the concrete schema emitted by pt_expt. PT SeZM's
# ``edge_vec`` lower and pt_expt's ``graph`` lower carry the same directed-edge
# model semantics, while the target materializes its native NeighborGraph ABI.
_LOWER_INPUT_KIND_TARGETS = {
"nlist": "nlist",
"graph": "graph",
"dpa1_canonical": "dpa1_canonical",
"dpa4c_canonical": "dpa4c_canonical",
"edge_vec": "graph",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking, though it should be a small fix. Mapping edge_vec to graph unconditionally turns a conversion that works today into a hard error for SeZM/DPA4 models that are not graph-lower eligible.

SeZMModel.export_lower_input_kind() returns "edge_vec" unconditionally — it never consults eligibility. SeZMPropertyModel subclasses SeZMModel and does not override it, so a property-fitting model reports "edge_vec" too. But model_uses_graph_lower returns False when "energy" not in model.atomic_output_def().keys(), which is exactly that case, so the innermost gate raises ValueError: lower_kind='graph' requested but the model is not graph-lower eligible.

Before this commit the same model converted fine: convert_backend passed "auto", and _resolve_lower_kind pre-checked model_uses_graph_lower and fell back to "nlist". Now the source's unconditional "edge_vec" is forwarded explicitly and skips that pre-check. So dp convert-backend sezm_property.pt out.pt2 regresses from working to failing, and the same applies to any other SeZM/DPA4 configuration that turns graph eligibility off — compression, set_davg_zero=False, use_three_body, disable_graph_lower().

The new test only exercises an energy-fitting DPA4 config, so nothing catches it.

The cleanest fix is probably to make the mapping conditional rather than a static table entry: resolve edge_vec to "graph" only when model_uses_graph_lower(model) holds, and fall back to "nlist" otherwise — i.e. put back the pre-check that "auto" used to perform. Alternatively, make SeZMModel.export_lower_input_kind() itself eligibility-aware, which would keep the table honest as a pure lookup. Either way a property-model case in test_convert_pt_dpa4_maps_edge_vec_to_graph's neighbourhood would pin it.

}


def _strip_shape_assertions(graph_module: torch.nn.Module) -> None:
"""Neutralise deferred shape-guard assertion nodes in an exported graph.
Expand Down Expand Up @@ -1242,7 +1254,8 @@ def serialize_from_file(model_file: str) -> dict:
dict
The serialized model data. If the archive contains
``model_def_script.json`` (training config), it is included
under the ``"model_def_script"`` key.
under the ``"model_def_script"`` key. ``lower_input_kind`` records
the concrete lower ABI from the artifact metadata.
"""
if model_file.endswith(".pt2"):
return _serialize_from_file_pt2(model_file)
Expand All @@ -1252,10 +1265,20 @@ def serialize_from_file(model_file: str) -> dict:

def _serialize_from_file_pte(model_file: str) -> dict:
"""Serialize a .pte model file to a dictionary."""
extra_files = {"model.json": "", "model_def_script.json": ""}
extra_files = {
"model.json": "",
"model_def_script.json": "",
"metadata.json": "",
}
torch.export.load(model_file, extra_files=extra_files)
model_dict = json.loads(extra_files["model.json"])
model_dict = _json_to_numpy(model_dict)
metadata = (
json.loads(extra_files["metadata.json"]) if extra_files["metadata.json"] else {}
)
model_dict["lower_input_kind"] = metadata.get(
"lower_input_kind", model_dict.get("lower_input_kind", "nlist")
)
if extra_files["model_def_script.json"]:
model_dict["model_def_script"] = json.loads(
extra_files["model_def_script.json"]
Expand All @@ -1273,6 +1296,7 @@ def _serialize_from_file_pt2(model_file: str) -> dict:

model_json_entry = PT2_EXTRA_PREFIX + "model.json"
model_def_script_entry = PT2_EXTRA_PREFIX + "model_def_script.json"
metadata_entry = PT2_EXTRA_PREFIX + "metadata.json"
with zipfile.ZipFile(model_file, "r") as zf:
names = zf.namelist()
if model_json_entry not in names:
Expand All @@ -1283,8 +1307,15 @@ def _serialize_from_file_pt2(model_file: str) -> dict:
model_def_script_json = ""
if model_def_script_entry in names:
model_def_script_json = zf.read(model_def_script_entry).decode("utf-8")
metadata_json = ""
if metadata_entry in names:
metadata_json = zf.read(metadata_entry).decode("utf-8")
model_dict = json.loads(model_json)
model_dict = _json_to_numpy(model_dict)
metadata = json.loads(metadata_json) if metadata_json else {}
model_dict["lower_input_kind"] = metadata.get(
"lower_input_kind", model_dict.get("lower_input_kind", "nlist")
)
if model_def_script_json:
model_dict["model_def_script"] = json.loads(model_def_script_json)
return model_dict
Expand Down Expand Up @@ -1355,6 +1386,17 @@ def _resolve_lower_kind(model_file: str, data: dict, lower_kind: str) -> str:
return "nlist"


def _resolve_target_lower_kind(model_file: str, data: dict, lower_kind: str) -> str:
"""Resolve a source lower ABI to a concrete pt_expt export schema."""
source_lower_kind = _resolve_lower_kind(model_file, data, lower_kind)
if source_lower_kind not in _LOWER_INPUT_KIND_TARGETS:
raise ValueError(
f"Unsupported lower_kind {source_lower_kind!r}; expected one of "
f"{sorted(_LOWER_INPUT_KIND_TARGETS)}."
)
return _LOWER_INPUT_KIND_TARGETS[source_lower_kind]


def deserialize_to_file(
model_file: str,
data: dict,
Expand Down Expand Up @@ -1393,14 +1435,17 @@ def deserialize_to_file(
(``atype``/``n_node``/``edge_index``/``edge_vec``/``edge_mask`` and
the destination/source CSR views) with a DYNAMIC edge axis ``E``
(``Dim("nedge", min=2)``), so the artifact accepts any system size.
``"auto"`` (used by ``convert-backend``) resolves to ``"graph"`` for an
exportable graph-lower ``.pt2`` and ``"nlist"`` otherwise (see
:func:`_resolve_lower_kind`). A graph lower always preserves the fused
inference operators (``DP_CUDA_INFER >= 2``) and the per-atom virial.
``"auto"`` resolves to ``"graph"`` for an exportable graph-lower
``.pt2`` and ``"nlist"`` otherwise (see :func:`_resolve_lower_kind`).
Backend conversion passes the source artifact's concrete lower kind;
compatible source ABIs are mapped to the target's native schema while
preserving their execution semantics. A graph lower always preserves
the fused inference operators (``DP_CUDA_INFER >= 2``) and the
per-atom virial.
The selected schema is recorded as ``lower_input_kind`` in
``metadata.json``.
"""
lower_kind = _resolve_lower_kind(model_file, data, lower_kind)
lower_kind = _resolve_target_lower_kind(model_file, data, lower_kind)
if data["model"].get("type") == "native_spin" and lower_kind not in (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but this guard can now be tripped by a value the caller never supplied, and its message then misdirects them.

_resolve_lower_kind special-cases type == "spin_ener" but not "native_spin", so a native-spin model arriving with "auto" — a .dp source, for instance — falls through to the generic eligibility branch and can resolve to "nlist" or, when canonical_model_eligible holds, "dpa1_canonical". Neither is in this guard's allowed set, so it raises and tells the user to pass lower_kind='graph' when they passed "auto", or nothing at all through dp convert-backend.

The mismatch predates this PR, but _resolve_target_lower_kind is new and sits directly in front of the guard, which makes it the natural place to either extend the native_spin special case into _resolve_lower_kind or reword the message to distinguish a caller-supplied kind from an internally-resolved one.

"graph",
"dpa4c_canonical",
Expand Down
1 change: 1 addition & 0 deletions deepmd/tf/utils/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def serialize_from_file(model_file: str) -> dict:
"tf_version": tf.__version__,
"model": model_dict,
"model_def_script": jdata["model"],
"lower_input_kind": "nlist",
}
# neighbor stat information
try:
Expand Down
1 change: 1 addition & 0 deletions deepmd/tf2/utils/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@ def serialize_from_file(model_file: str) -> dict:
"backend": "TensorFlow2",
"model": model_payload,
"model_def_script": model_def_script,
"lower_input_kind": "nlist",
"shared_links": state.get("shared_links"),
"@variables": {
"current_step": int(state.get("current_step", 0)),
Expand Down
9 changes: 9 additions & 0 deletions doc/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,12 @@ then selects its `edge_vec`, dense `nlist`, NeighborGraph, or compact
## Convert model files between backends

If a model is supported by two backends, one can use [`dp convert-backend`](./cli.rst) to convert the model file between these two backends.

Backend conversion preserves the concrete `lower_input_kind` reported by the
source serializer. Dense TensorFlow, TensorFlow 2, JAX, and standard PyTorch
models therefore remain dense `nlist` models when converted to `.pt2`; model
families with a graph-native deployment ABI report their corresponding kind.
Compiled `.pt2` and `.pte` artifacts retain the kind recorded in their metadata.
A conversion is rejected when the target backend cannot represent an explicit
source kind. Legacy model files without lower metadata retain target-specific
automatic selection for backward compatibility.
4 changes: 4 additions & 0 deletions source/tests/consistent/io/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ def setUp(self) -> None:
"model": model.serialize(),
"backend": "test",
"model_def_script": model_def_script,
"lower_input_kind": "nlist",
}

def tearDown(self) -> None:
Expand Down Expand Up @@ -319,6 +320,7 @@ def setUp(self) -> None:
"model": model.serialize(),
"backend": "test",
"model_def_script": model_def_script,
"lower_input_kind": "nlist",
}

def tearDown(self) -> None:
Expand Down Expand Up @@ -375,6 +377,7 @@ def setUp(self) -> None:
"model": model.serialize(),
"backend": "test",
"model_def_script": model_def_script,
"lower_input_kind": "nlist",
}

def tearDown(self) -> None:
Expand Down Expand Up @@ -422,6 +425,7 @@ def setUp(self) -> None:
"model": model.serialize(),
"backend": "test",
"model_def_script": model_def_script,
"lower_input_kind": "nlist",
}

def tearDown(self) -> None:
Expand Down
56 changes: 56 additions & 0 deletions source/tests/jax/test_hlo.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Regression tests for metadata exposed by serialized JAX HLO models."""

from types import (
SimpleNamespace,
)

import pytest
from typing_extensions import (
Self,
)

from deepmd.jax.model.hlo import (
HLO,
)
from deepmd.jax.utils import (
serialization,
)


def test_hlo_get_nnei_uses_stored_selection() -> None:
Expand All @@ -17,3 +29,47 @@ def test_hlo_get_nnei_uses_stored_selection() -> None:
model.sel = [6, 12, 1]

assert model.get_nnei() == sum(model.sel)


def test_hlo_serialization_declares_dense_lower(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A JAX HLO artifact exposes its dense source execution semantics."""
stored_data = {
"model": {},
"constants": {},
"@variables": {"stablehlo": b"module"},
}
monkeypatch.setattr(serialization, "load_dp_model", lambda _path: stored_data)

data = serialization.serialize_from_file("model.hlo")

assert data["lower_input_kind"] == "nlist"


def test_checkpoint_serialization_declares_dense_lower(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A JAX training checkpoint exposes its dense source semantics."""

class Checkpointer:
def __init__(self, _handler: object) -> None:
pass

def __enter__(self) -> Self:
return self

def __exit__(self, *_args: object) -> None:
pass

def restore(self, *_args: object, **_kwargs: object) -> SimpleNamespace:
return SimpleNamespace(
state={},
model_def_script={"model_dict": {}},
)

monkeypatch.setattr(serialization.ocp, "Checkpointer", Checkpointer)

data = serialization.serialize_from_file("model.jax")

assert data["lower_input_kind"] == "nlist"
4 changes: 4 additions & 0 deletions source/tests/pt/model/test_ener_spin_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ def test_output_shape(
torch.testing.assert_close(result["force"].shape, [nframes, nloc, 3])
torch.testing.assert_close(result["force_mag"].shape, [nframes, nloc, 3])

def test_export_lower_input_kind(self) -> None:
"""Virtual-atom spin models retain the dense export ABI."""
self.assertEqual(self.model.export_lower_input_kind(), "nlist")

def test_input_output_process(self) -> None:
nframes, nloc = self.coord.shape[:2]
self.real_ntypes = self.model.spin.get_ntypes_real()
Expand Down
Loading
Loading