Skip to content

Commit 8ef72a3

Browse files
committed
fix(pt_expt): distinguish raw checkpoint backends by weights
1 parent 7c48a6c commit 8ef72a3

3 files changed

Lines changed: 135 additions & 36 deletions

File tree

deepmd/backend/pt_expt.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
22
from collections.abc import (
33
Callable,
4+
Mapping,
45
)
56
from importlib.util import (
67
find_spec,
78
)
89
from typing import (
910
TYPE_CHECKING,
11+
Any,
1012
ClassVar,
1113
)
1214

@@ -27,6 +29,46 @@
2729
)
2830

2931

32+
def detect_pt_checkpoint_backend(checkpoint: Any) -> str | None:
33+
"""Detect the backend dialect of a raw PyTorch checkpoint.
34+
35+
Parameters
36+
----------
37+
checkpoint : Any
38+
A checkpoint payload or its unwrapped model state dictionary.
39+
40+
Returns
41+
-------
42+
str or None
43+
``"pt-expt"`` or ``"pt"`` when the parameter names identify one
44+
backend unambiguously, otherwise ``None``.
45+
"""
46+
state_dict = checkpoint
47+
if isinstance(state_dict, Mapping) and "model" in state_dict:
48+
state_dict = state_dict["model"]
49+
if not isinstance(state_dict, Mapping):
50+
return None
51+
52+
keys = tuple(key for key in state_dict if isinstance(key, str))
53+
54+
# Weight names are decisive. pt_expt DPA4 also contains ordinary
55+
# torch-native ``.bias`` parameters, so bias names cannot override a
56+
# clear ``.w`` versus ``.matrix`` distinction.
57+
has_pt_expt_weight = any(key.endswith(".w") for key in keys)
58+
has_pt_weight = any(key.endswith(".matrix") for key in keys)
59+
if has_pt_expt_weight or has_pt_weight:
60+
if has_pt_expt_weight == has_pt_weight:
61+
return None
62+
return "pt-expt" if has_pt_expt_weight else "pt"
63+
64+
# Bias-only state dictionaries retain the original dialect fallback.
65+
has_pt_expt_bias = any(key.endswith(".b") for key in keys)
66+
has_pt_bias = any(key.endswith(".bias") for key in keys)
67+
if has_pt_expt_bias == has_pt_bias:
68+
return None
69+
return "pt-expt" if has_pt_expt_bias else "pt"
70+
71+
3072
@Backend.register("pt-expt")
3173
@Backend.register("pytorch-exportable")
3274
class PyTorchExportableBackend(Backend):
@@ -51,11 +93,8 @@ def match_filename(cls, filename: str) -> int:
5193
Returns
5294
-------
5395
- 1 for the regular `.pte` / `.pt2` suffixes (default behaviour).
54-
- 2 for `.pt` files whose state-dict uses pt_expt's dpmodel
55-
parameter naming (`.w`/`.b`); this outranks the legacy pt
56-
backend's default suffix score (1) so pt_expt-trained `.pt`
57-
checkpoints route here, while genuine pt-trained `.pt` files
58-
(which use `.matrix`/`.bias`) keep going to the pt backend.
96+
- 2 for `.pt` files whose state dictionary uses the pt_expt parameter
97+
dialect. This outranks the pt backend's default suffix score (1).
5998
- 0 otherwise.
6099
"""
61100
score = super().match_filename(filename)
@@ -69,21 +108,14 @@ def match_filename(cls, filename: str) -> int:
69108

70109
# weights_only=True avoids unpickling arbitrary code from an
71110
# untrusted .pt — sniffing only needs the dict keys.
72-
sd = torch.load(filename, map_location="cpu", weights_only=True)
111+
checkpoint = torch.load(filename, map_location="cpu", weights_only=True)
73112
except Exception:
74113
# Not a valid torch archive (corrupt file, wrong format, or a
75114
# weights_only=True restriction trip). Surrender the claim so
76115
# the dispatcher falls back to the default suffix match — pt's
77116
# default score (1) will pick up the file under `dp --pt`.
78117
return 0
79-
if isinstance(sd, dict) and "model" in sd:
80-
sd = sd["model"]
81-
keys = list(sd.keys()) if hasattr(sd, "keys") else []
82-
has_pt_expt = any(k.endswith(".w") or k.endswith(".b") for k in keys)
83-
has_pt = any(k.endswith(".matrix") or k.endswith(".bias") for k in keys)
84-
if has_pt_expt and not has_pt:
85-
return 2
86-
return 0
118+
return 2 if detect_pt_checkpoint_backend(checkpoint) == "pt-expt" else 0
87119

88120
def is_available(self) -> bool:
89121
"""Check if the backend is available.

deepmd/pt_expt/infer/deep_eval.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
import numpy as np
1414
import torch
1515

16+
from deepmd.backend.pt_expt import (
17+
detect_pt_checkpoint_backend,
18+
)
1619
from deepmd.dpmodel.model.transform_output import (
1720
communicate_extended_output,
1821
)
@@ -132,18 +135,6 @@ def _reshape_charge_spin(
132135
) from err
133136

134137

135-
def _is_pt_backend_dpa4_params(model_params: dict[str, Any]) -> bool:
136-
"""Return whether a training checkpoint should be loaded by the pt backend."""
137-
model_type = str(model_params.get("type", "")).lower()
138-
if model_type in {"sezm", "dpa4", "sezm_spin"}:
139-
return True
140-
descriptor = model_params.get("descriptor")
141-
if isinstance(descriptor, dict):
142-
descriptor_type = str(descriptor.get("type", "")).lower()
143-
return descriptor_type in {"sezm", "dpa4"}
144-
return False
145-
146-
147138
def _warn_legacy_edge_vec(metadata: dict) -> None:
148139
"""Warn once per model load when an edge_vec-schema artifact is opened.
149140
@@ -556,9 +547,17 @@ def _load_pt(self, model_file: str, head: str | None = None) -> None:
556547

557548
# Match the training resume path (training.py:712) — weights_only=True
558549
# avoids unpickling arbitrary code from untrusted checkpoints.
559-
state_dict = torch.load(model_file, map_location=DEVICE, weights_only=True)
550+
checkpoint = torch.load(model_file, map_location=DEVICE, weights_only=True)
551+
checkpoint_backend = detect_pt_checkpoint_backend(checkpoint)
552+
state_dict = checkpoint
560553
if isinstance(state_dict, dict) and "model" in state_dict:
561554
state_dict = state_dict["model"]
555+
if checkpoint_backend == "pt":
556+
raise ValueError(
557+
f"Checkpoint '{model_file}' uses the regular `pt` parameter "
558+
"dialect. Load it with `dp --pt`, or export it to `.pt2` / "
559+
"`.pte` before loading it with `pt_expt`."
560+
)
562561
extra = state_dict.get("_extra_state") if isinstance(state_dict, dict) else None
563562
if not (isinstance(extra, dict) and "model_params" in extra):
564563
raise ValueError(
@@ -598,13 +597,6 @@ def _load_pt(self, model_file: str, head: str | None = None) -> None:
598597
state_dict = head_state
599598
model_params = head_params
600599

601-
if _is_pt_backend_dpa4_params(model_params):
602-
raise ValueError(
603-
"DPA4/SeZM `.pt` checkpoints belong to the regular `pt` backend. "
604-
"Use the `pt` backend for eager checkpoint inference, or export "
605-
"the checkpoint to `.pt2` / `.pte` before loading it with `pt_expt`."
606-
)
607-
608600
model = get_model(deepcopy(model_params)).to(DEVICE)
609601

610602
# Strip the `_CompiledModel` wrapper that pt_expt training applies

source/tests/pt_expt/infer/test_deep_eval_pt_checkpoint.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
Covers two pieces:
55
66
1. ``Backend.detect_backend_by_model`` sniffs ``.pt`` content
7-
(``.w``/``.b`` -> pt_expt, ``.matrix``/``.bias`` -> pt) so that
8-
``dp test -m foo.pt`` routes to the right backend.
7+
(``.w`` weights -> pt_expt, ``.matrix`` weights -> pt, with bias names
8+
used only as a fallback) so that ``dp test -m foo.pt`` routes to the
9+
right backend.
910
2. ``pt_expt.DeepEval._load_pt`` reconstructs the model from
1011
``_extra_state["model_params"]``, loads ``state_dict``, and runs
1112
inference in eager mode, producing outputs that match a direct
@@ -710,6 +711,80 @@ def _spin_eager_reference(model, COORD, ATYPE, SPIN, BOX):
710711
return {k: v.detach().cpu().numpy() for k, v in ref.items()}
711712

712713

714+
class TestPtExptLoadPtNativeSpinDPA4(unittest.TestCase):
715+
"""A native-spin DPA4 checkpoint reaches the graph-spin eager runner."""
716+
717+
@classmethod
718+
def setUpClass(cls) -> None:
719+
from ..model.test_dpa4_native_spin import (
720+
NATIVE_SPIN_CONFIG,
721+
_build_native_spin_model_cpu,
722+
)
723+
from .test_deep_eval_spin import (
724+
ATYPE,
725+
BOX,
726+
COORD,
727+
SPIN,
728+
)
729+
730+
cls.ATYPE = ATYPE
731+
cls.BOX = BOX
732+
cls.COORD = COORD
733+
cls.SPIN = SPIN
734+
cls.model = _build_native_spin_model_cpu().to(DEVICE).eval()
735+
cls.ref = _spin_eager_reference(
736+
cls.model, cls.COORD, cls.ATYPE, cls.SPIN, cls.BOX
737+
)
738+
cls.pt_path = tempfile.NamedTemporaryFile(suffix=".pt", delete=False).name
739+
_save_pt_checkpoint(
740+
cls.model,
741+
copy.deepcopy(NATIVE_SPIN_CONFIG),
742+
cls.pt_path,
743+
)
744+
745+
@classmethod
746+
def tearDownClass(cls) -> None:
747+
if os.path.exists(cls.pt_path):
748+
os.unlink(cls.pt_path)
749+
750+
def test_auto_dispatch_and_eval_match_eager(self) -> None:
751+
backend = Backend.detect_backend_by_model(self.pt_path)
752+
self.assertIs(backend, Backend.get_backend("pt-expt"))
753+
754+
dp = DeepPot(
755+
self.pt_path,
756+
auto_batch_size=False,
757+
neighbor_graph_method="dense",
758+
)
759+
self.assertTrue(dp.has_spin)
760+
self.assertEqual(dp.deep_eval.metadata["lower_input_kind"], "graph")
761+
762+
energy, force, virial, force_mag, mask_mag = dp.eval(
763+
self.COORD,
764+
self.BOX,
765+
self.ATYPE,
766+
atomic=False,
767+
spin=self.SPIN,
768+
)
769+
for name, actual in (
770+
("energy", energy),
771+
("force", force),
772+
("virial", virial),
773+
("force_mag", force_mag),
774+
):
775+
np.testing.assert_allclose(
776+
actual.reshape(-1),
777+
self.ref[name].reshape(-1),
778+
rtol=1e-10,
779+
atol=1e-10,
780+
err_msg=name,
781+
)
782+
np.testing.assert_array_equal(
783+
mask_mag.reshape(-1),
784+
self.ref["mask_mag"].reshape(-1),
785+
)
786+
787+
713788
class _SpinFilesMixin:
714789
"""Build .pt + .pte for the chosen ``spin_config`` once per class."""
715790

0 commit comments

Comments
 (0)