Skip to content

Commit ced0016

Browse files
wanghan-iapcmHan Wangpre-commit-ci[bot]
authored
perf(dpa4): batch the SO3/grid contractions over (D,F); keep use_amp through pt_expt assembly (deepmodeling#5960)
Users reported that compiled DPA4 training runs ~2x slower on `pt_expt` than on `pt`. This PR is the result of chasing that: a performance bug in how the SO3 contractions were lowered, and a correctness bug where a configured `use_amp: false` was lost while `pt_expt` assembled the model. ## Changes **1. Weight broadcast across the node axis (`so3.py`, `lora.py`, `grid_net.py`)** `matmul(x[..., None, :], weight[None, ...])` makes the node count `N` the matmul BATCH, so matmul broadcasts the weight to `(N, D, F, Cin, Cout)` and autograd then reduces that whole expanded gradient (`ExpandBackward0`) back to the parameter shape. At the water example's sizes a 165 K-element weight expanded to 191 M elements (~0.8 GB) per call, and the reduce was the single costliest kernel of a training step (45.6 ms, 3x per step). The fix batches the contraction over the small `(D, F)` axes so `N` stays the GEMM ROW dimension and the weight is used in place. Micro-benchmark, fwd+bwd at the real shapes: **16.48 ms -> 1.09 ms (15x)**. Two lookalike sites in `projection.py` are deliberately NOT changed: their operands are `requires_grad=False` buffers, so no backward reduce exists. Verified rather than assumed. **2. The same lowering for the frame mixers (`_degree_batched_matmul`)** Review found the `FrameContract` / `FrameExpand` mixers still on the broadcast spelling. They now share one helper, `_degree_batched_matmul`, written identically on the dpmodel side (`dpmodel/descriptor/dpa4_nn/grid_net.py`) and the pt side (`pt/model/descriptor/sezm_nn/grid_net.py`). Because it does no reshape, an empty node axis (`N == 0`) flows through unchanged instead of hitting a reshape error — pinned by a test. **3. `use_amp` was lost during `pt_expt` model assembly (correctness)** `use_amp` is a training-runtime policy, not model state, so it stays OUT of the portable serialization record (this is the deepmodeling#5963 position, and the jax deserializer actively rejects records carrying `use_amp: true`). The bug was elsewhere: `pt_expt` assembled its model by converting an already-populated dpmodel instance, and that conversion round-trips the component through `deserialize(serialize())`. Anything that is deliberately not in the portable record — `use_amp` among it — was therefore dropped, and training silently ran under bfloat16 autocast even when the input configured `use_amp: false`. The fix is at the assembly boundary, not in the record: `pt_expt` now constructs the wrapped class directly (`auto_wrapped_class(...)` in `make_model.py`, `get_model.py`, and the bridging composition path), so a live constructor-supplied component keeps its runtime state. The rule is stated once, in the `auto_wrapped_class` docstring; the call sites reference it. An earlier revision of this PR instead added `use_amp` to `serialize()`. That was reverted in review — it put a runtime knob into the portable record and would have broken the jax contract. **Also removed in review: an `enable_tf32` / `DP_TF32_INFER` implementation for `pt_expt`.** It contributes nothing to the speedup measured below (the benchmark card has no TF32 silicon), and deepmodeling#5958 owns the `pt_expt` training-runtime alignment — including the documented position that `pt_expt` always runs at `"highest"` matmul precision. `pt_expt` therefore keeps master's warn-and-ignore behavior for `enable_tf32`. ## Benchmark DPA4 water example (`examples/water/dpa4`), one Tesla T4, torch 2.11, fp32 (`use_amp: false`), batch size 6. Steady-state seconds per training step, obtained by differencing the wall time of a 33-step and a 3-step run of the same config, which cancels every one-time cost (import, data load, statistics, `torch.compile` / make_fx lowering). All five arms were measured in one session on the same machine; run-to-run variation is about 2-3%. **Provenance: measured at `ae720432b`**, the head at which this PR was opened — i.e. BEFORE the review changes (change 2, the frame-mixer lowering, and change 3's move from `serialize()` to the assembly boundary). Change 1, which is where the entire speedup comes from, is unmodified since. The numbers have not been re-measured on the current head; a re-run is pending and I will post it rather than silently reuse these. | training mode | `pt` (reference) | `pt_expt` at master | `pt_expt` at `ae720432b` | speedup vs master | |---|---|---|---|---| | eager | 0.891 s/step | 1.555 s/step | **0.921 s/step** | **1.69x** | | compiled | 0.545 s/step | 1.611 s/step | **0.535 s/step** | **3.01x** | This reproduces the reported issue at master — `pt_expt` compiled was 3.0x slower than `pt` compiled, and even slower than its own eager path, because the broadcast-weight contraction lowers to worse code under inductor than under eager cuBLAS. After the fix `pt_expt` is at parity with `pt`: eager within 3.4%, compiled within measurement noise. ## Known limitations - **Backward numerics are covered for the frame mixers, not for the SO3 / LoRA contractions.** `test_dpa4_frame_mixers.py` compares `_degree_batched_matmul`'s weight gradient against the pt module's at rtol/atol 1e-12. The rewritten SO3 and LoRA contractions are pinned on the forward against an explicit `einsum` reference (rtol/atol 1e-12, numpy and torch namespaces); their backward is still exercised only by tracing, not compared by value. - **The `pt` / `pt_expt` TF32 policy gap remains open.** On Ampere+ cards `pt` runs training matmuls under TF32 (`enable_tf32`, default `True`) while `pt_expt` ignores the key with a warning; the two backends are not speed-comparable there. Deferred to the deepmodeling#5958 training-runtime series. - **The residual compiled gap vs `pt` is not stable across sessions.** An earlier session measured `pt_expt` compiled 10.8% slower than `pt` compiled; the benchmark above measured it 1.7% faster. Both are within a couple of run-to-run standard deviations, so I treat compiled as at parity and the earlier gap as unconfirmed. - The history contains churn at the GridBranch router (`7518a417c` -> `01c58e665` -> `75459610a` -> `504bb2430` -> `157444204`): a matmul spelling introduced, reverted, reintroduced, and finally restored to master's line. The site is byte-identical to master in the final diff. The degenerate GEMM that profiling found there existed only on this branch, so it is not a fix — I have left the commits rather than rewriting pushed history, and would squash them on request. - Unrelated but found while benchmarking: **torch >= 2.11 ships no Volta (CC 7.0) kernels**, and compiled training requires >= 2.11 via `check_compile_torch_version`. Compiled DPA4 training is therefore impossible on V100 with official wheels; T4 (CC 7.5) is the oldest card that works. ## Tests - `source/tests/common/dpmodel/test_dpa4_frame_mixers.py` — `_degree_batched_matmul` vs the pt module: forward parity, the `N == 0` contract, and weight-gradient parity. - `source/tests/common/dpmodel/test_dpa4_lora.py` — new `test_lora_so3_call_matches_einsum_contract`: `LoRASO3.call` against the explicit `einsum("ndfi,difo->ndfo")` reference with a nonzero adapter, on both the numpy and torch namespaces, for `n_focus` 1 and 2. - `source/tests/pt_expt/model/test_get_model_dpa4.py` — `use_amp` survives model assembly (both branches), for the plain and the bridged/composed construction paths. - `source/tests/common/dpmodel/test_descrpt_dpa4.py` — `use_amp` is absent from the portable serialization record and defaults on deserialize. - Existing `test_grid_branch[1]`/`[2]` cover the changed SO3 contraction against the pt implementation at rtol 1e-12. - Run locally: 434 passed / 10 skipped across the dpa4 dpmodel, pt_expt and cross-backend parity suites, plus the pt_expt model suite. CUDA-gated precision-context cases were run on a T4 (29/29). ## Test status caveat — resolved An earlier revision of this description flagged two locally failing `pt_expt` AOTI-freeze tests (`test_zbl_bridging.py::test_native_spin_with_bridging_graph_freeze_and_deep_eval`, `test_dpa4_zbl_parallel.py::TestBridgedSpinGraphSelfComm::test_freeze_embeds_with_comm_artifact`) as unadjudicated. They are now adjudicated as **pre-existing and environmental, not caused by this branch**: a clean `upstream/master` worktree on the same machine fails both with the identical `InductorError: assert isinstance(index, CppCSEVariable) and index.is_vec` (torch 2.11 CPU-SIMD codegen bug on an `atomic_add` scatter buffer), and both tests pass on this branch with the known workaround `torch._inductor.config.cpp.simdlen = 1` (2 passed). The same bug is already documented in `source/tests/infer/gen_dpa4.py` / `gen_dpa2.py`. --------- Co-authored-by: Han Wang <wang_han@iapcm.ac.cn> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ed691aa commit ced0016

12 files changed

Lines changed: 438 additions & 34 deletions

File tree

deepmd/dpmodel/descriptor/dpa4_nn/grid_net.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,37 @@ def _build_frame_degree_index(
9999
raise ValueError("`coefficient_layout` must be either 'packed' or 'm_major'")
100100

101101

102+
def _degree_batched_matmul(xp: Any, coeff: Any, weight: Any) -> Any:
103+
"""Contract ``einsum("ndfi,dio->ndfo")`` batched over the degree axis.
104+
105+
Parameters
106+
----------
107+
xp : Any
108+
The array namespace of ``coeff``.
109+
coeff : Array
110+
Coefficients with shape ``(N, D, F, i)``.
111+
weight : Array
112+
Per-degree weights with shape ``(D, i, o)``.
113+
114+
Returns
115+
-------
116+
Array
117+
Contracted coefficients with shape ``(N, D, F, o)``.
118+
119+
Notes
120+
-----
121+
Batching over the ``(D, F)`` axes, not over ``N``: expanding ``weight``
122+
across ``F`` costs ``D*F*i*o`` elements, whereas batching over ``N``
123+
(or collapsing ``N*F``, which needs a materialized permuted copy of
124+
``coeff``) touches ``N*D*F*i`` elements — a factor ``N/o`` more. No
125+
reshape is involved, so an empty ``N`` batch (empty graph/edge set, or
126+
a distributed rank owning no nodes) flows through naturally.
127+
"""
128+
coeff_df = xp.permute_dims(coeff, (1, 2, 0, 3)) # (D, F, N, i)
129+
out = xp.matmul(coeff_df, weight[:, None, :, :]) # (D, F, N, o)
130+
return xp.permute_dims(out, (2, 0, 1, 3)) # (N, D, F, o)
131+
132+
102133
def _project_frames(coeff: Any, proj: ChannelLinear, n_frames: int) -> Any:
103134
"""
104135
Apply a channel-only linear map to each Wigner-D frame independently.
@@ -493,9 +524,8 @@ def call(self, coeff: Any) -> Any:
493524
weight = xp_asarray_nodetach(xp, self.weight[...], device=device)
494525
degree_index = xp_asarray_nodetach(xp, self.degree_index, device=device)
495526
weight = xp.take(weight, degree_index, axis=0)
496-
# einsum "ndfi,dio->ndfo" as a broadcast batched matmul:
497-
# (N, D, F, i) @ (1, D, i, o) -> (N, D, F, o)
498-
return xp.matmul(coeff, weight[None, ...])
527+
# Batched over the (D, F) axes, never over N -- see the helper's note.
528+
return _degree_batched_matmul(xp, coeff, weight)
499529

500530
def serialize(self) -> dict[str, Any]:
501531
"""Serialize the FrameContract to a dict."""
@@ -575,9 +605,8 @@ def call(self, coeff: Any) -> Any:
575605
weight = xp_asarray_nodetach(xp, self.weight[...], device=device)
576606
degree_index = xp_asarray_nodetach(xp, self.degree_index, device=device)
577607
weight = xp.take(weight, degree_index, axis=0)
578-
# einsum "ndfi,dio->ndfo" as a broadcast batched matmul:
579-
# (N, D, F, i) @ (1, D, i, o) -> (N, D, F, o)
580-
return xp.matmul(coeff, weight[None, ...])
608+
# Batched over the (D, F) axes, never over N -- see the helper's note.
609+
return _degree_batched_matmul(xp, coeff, weight)
581610

582611
def serialize(self) -> dict[str, Any]:
583612
"""Serialize the FrameExpand to a dict."""

deepmd/dpmodel/descriptor/dpa4_nn/lora.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,12 @@ def call(self, x: Array) -> Array:
189189
)
190190
expand_index = xp_asarray_nodetach(xp, self.expand_index, device=device)
191191
weight_expanded = xp.take(weight, expand_index, axis=0)
192-
# einsum "ndfi,difo->ndfo" as a broadcast batched matmul:
193-
# (N, D, F, 1, Cin) @ (1, D, F, Cin, Cout) -> (N, D, F, 1, Cout)
192+
# einsum "ndfi,difo->ndfo", batched over the small (D, F) axes rather
193+
# than over N, which would broadcast the weight and make autograd
194+
# reduce the expansion. LoRA twin of the so3.py contraction.
194195
weight_expanded = xp.permute_dims(weight_expanded, (0, 2, 1, 3))
195-
out = xp.matmul(x[:, :, :, None, :], weight_expanded[None, ...])[..., 0, :]
196+
out = xp.matmul(xp.permute_dims(x, (1, 2, 0, 3)), weight_expanded)
197+
out = xp.permute_dims(out, (2, 0, 1, 3)) # (N, D, F, Cout)
196198
if self.mlp_bias:
197199
bias = xp.reshape(
198200
xp_asarray_nodetach(xp, self.bias[...], device=device),

deepmd/dpmodel/descriptor/dpa4_nn/so3.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,13 @@ def call(self, x: Any) -> Any:
131131
xp, self.weight[...], device=array_api_compat.device(x)
132132
)
133133
weight = xp.reshape(weight, (self.in_channels, self.n_focus, self.out_channels))
134-
# einsum "bfi,ifo->bfo" as a broadcast batched matmul:
135-
# (B, F, 1, Cin) @ (1, F, Cin, Cout) -> (B, F, 1, Cout)
134+
# einsum "bfi,ifo->bfo" as F independent (B, Cin) x (Cin, Cout) GEMMs.
135+
# B stays the GEMM rows so the weight is used in place; making B the
136+
# batch axis would broadcast it to (B, F, Cin, Cout) and leave autograd
137+
# reducing that expansion. At n_focus=1 both permutes are free views.
136138
weight = xp.permute_dims(weight, (1, 0, 2)) # (F, Cin, Cout)
137-
out = xp.matmul(x[:, :, None, :], weight[None, ...])[..., 0, :]
139+
out = xp.matmul(xp.permute_dims(x, (1, 0, 2)), weight) # (F, B, Cout)
140+
out = xp.permute_dims(out, (1, 0, 2)) # (B, F, Cout)
138141
if self.use_bias:
139142
bias = xp_asarray_nodetach(
140143
xp, self.bias[...], device=array_api_compat.device(x)
@@ -439,12 +442,14 @@ def call(self, x: Any) -> Any:
439442
weight_expanded = xp.take(weight, expand_index, axis=0) # (D, Cin, F, Cout)
440443

441444
# === Step 2. Per-focus, per-degree channel mixing ===
442-
# einsum "ndfi,difo->ndfo" as a broadcast batched matmul:
443-
# (N, D, F, 1, Cin) @ (1, D, F, Cin, Cout) -> (N, D, F, 1, Cout)
445+
# einsum "ndfi,difo->ndfo". Batch over (D, F) so N remains the GEMM
446+
# row dimension: this avoids materializing N copies of the weight and
447+
# the corresponding gradient reduction on every backward.
444448
weight_expanded = xp.permute_dims(
445449
weight_expanded, (0, 2, 1, 3)
446450
) # (D, F, Cin, Cout)
447-
out = xp.matmul(x[:, :, :, None, :], weight_expanded[None, ...])[..., 0, :]
451+
out = xp.matmul(xp.permute_dims(x, (1, 2, 0, 3)), weight_expanded)
452+
out = xp.permute_dims(out, (2, 0, 1, 3)) # (N, D, F, Cout)
448453

449454
# === Step 3. Add l=0 bias ===
450455
if self.mlp_bias:

deepmd/dpmodel/model/model_factory.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def get_linear_atomic_model(
135135
backend_name: str,
136136
atomic_model: type,
137137
pairtab_model: type,
138+
linear_atomic_model: type | None = None,
138139
descriptor_child_builder: "Callable[[dict], Any | None] | None" = None,
139140
) -> Any:
140141
"""Build the ``LinearEnergyAtomicModel`` composition from a config.
@@ -162,6 +163,13 @@ def get_linear_atomic_model(
162163
Backend learned atomic-model class.
163164
pairtab_model : type
164165
Backend pair-tabulation atomic-model class.
166+
linear_atomic_model : type, optional
167+
Backend linear composition atomic-model class. Defaults to the
168+
dpmodel class. A backend that wraps dpmodel classes must pass its
169+
own wrapper: otherwise the composition it gets back is a dpmodel
170+
instance that its model wrapper has to convert, and conversion
171+
keeps only what the portable record carries -- dropping any
172+
runtime state the children hold (e.g. ``use_amp``).
165173
descriptor_child_builder : callable, optional
166174
Backend hook for descriptor-bearing children: called with the
167175
child config (``type_map`` and derived clamp radii already
@@ -183,9 +191,11 @@ def get_linear_atomic_model(
183191
InnerPotentialAtomicModel,
184192
)
185193
from deepmd.dpmodel.atomic_model.linear_atomic_model import (
186-
LinearEnergyAtomicModel,
194+
LinearEnergyAtomicModel as LinearEnergyAtomicModelDP,
187195
)
188196

197+
LinearEnergyAtomicModel = linear_atomic_model or LinearEnergyAtomicModelDP
198+
189199
data = copy.deepcopy(data)
190200
type_map = data["type_map"]
191201
children = data["models"]
@@ -433,6 +443,7 @@ def __init__(
433443
atomic_model: type | None = None,
434444
pairtab_model: type | None = None,
435445
zbl_model: type | None = None,
446+
linear_atomic_model: type | None = None,
436447
) -> None:
437448
"""Store backend-native classes used by all model construction paths."""
438449
self.descriptor_base = descriptor_base
@@ -442,6 +453,7 @@ def __init__(
442453
self.atomic_model = atomic_model
443454
self.pairtab_model = pairtab_model
444455
self.zbl_model = zbl_model
456+
self.linear_atomic_model = linear_atomic_model
445457

446458
def get_model_components(self, data: dict) -> tuple[Any, Any, str]:
447459
"""Construct descriptor and fitting objects for this backend."""
@@ -478,6 +490,7 @@ def get_linear_atomic_model(
478490
backend_name=self.backend_name,
479491
atomic_model=self.atomic_model,
480492
pairtab_model=self.pairtab_model,
493+
linear_atomic_model=self.linear_atomic_model,
481494
descriptor_child_builder=descriptor_child_builder,
482495
)
483496

deepmd/pt/model/descriptor/sezm_nn/grid_net.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,20 @@ def forward(
343343
return _project_frames(from_grid(out), self.out_proj, self.n_frames)
344344

345345

346+
def _degree_batched_matmul(coeff: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
347+
"""Contract ``einsum("ndfi,dio->ndfo", coeff, weight)``.
348+
349+
Batched over the ``(D, F)`` axes, not over ``N`` (and not by collapsing
350+
``N*F``, which would materialize a permuted copy of ``coeff``):
351+
expanding ``weight`` across ``F`` costs ``D*F*i*o`` elements versus
352+
``N*D*F*i`` for the coefficient copy -- a factor ``N/o`` more. No
353+
reshape is involved, so an empty ``N`` batch flows through naturally.
354+
"""
355+
coeff_df = coeff.permute(1, 2, 0, 3) # (D, F, N, i)
356+
out = torch.matmul(coeff_df, weight.unsqueeze(1)) # (D, F, N, o)
357+
return out.permute(2, 0, 1, 3) # (N, D, F, o)
358+
359+
346360
class FrameContract(nn.Module):
347361
"""Per-degree frame/channel contraction that preserves the order index."""
348362

@@ -387,7 +401,7 @@ def __init__(
387401
def forward(self, coeff: torch.Tensor) -> torch.Tensor:
388402
"""Contract ``(N, D, F, K*C)`` frame coefficients to ``(N, D, F, C)``."""
389403
weight = self.weight.index_select(0, self.degree_index)
390-
return torch.einsum("ndfi,dio->ndfo", coeff, weight)
404+
return _degree_batched_matmul(coeff, weight)
391405

392406

393407
class FrameExpand(nn.Module):
@@ -434,7 +448,7 @@ def __init__(
434448
def forward(self, coeff: torch.Tensor) -> torch.Tensor:
435449
"""Expand ``(N, D, F, C)`` coefficients to ``(N, D, F, K*C)``."""
436450
weight = self.weight.index_select(0, self.degree_index)
437-
return torch.einsum("ndfi,dio->ndfo", coeff, weight)
451+
return _degree_batched_matmul(coeff, weight)
438452

439453

440454
class BaseGridNet(nn.Module):

deepmd/pt_expt/common.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,32 +136,54 @@ def try_convert_module(value: Any) -> torch.nn.Module | None:
136136
_AUTO_WRAPPED_CLASSES: dict[type, type] = {}
137137

138138

139-
def _auto_wrap_native_op(value: NativeOP) -> torch.nn.Module:
140-
"""Auto-wrap any NativeOP as a torch.nn.Module via ``torch_module``.
139+
def auto_wrapped_class(cls: type) -> type:
140+
"""Return the cached ``torch_module`` auto-wrap of a dpmodel class.
141141
142142
Creates a subclass with a generic ``forward`` that delegates to ``call``,
143143
then applies ``torch_module`` to get full ``__setattr__`` / post-init
144144
list conversion. The wrapped class is cached per dpmodel type.
145145
146+
Invariant: construct this wrapped class directly whenever live,
147+
constructor-supplied components must retain non-serialized runtime
148+
state. Converting a populated raw dpmodel instance instead goes
149+
through the ``serialize()``/``deserialize()`` round-trip of
150+
``_auto_wrap_native_op``, which preserves only the portable record.
151+
146152
Parameters
147153
----------
148-
value : NativeOP
149-
The dpmodel object to wrap.
154+
cls : type
155+
The dpmodel NativeOP class to wrap.
150156
151157
Returns
152158
-------
153-
torch.nn.Module
154-
The wrapped pt_expt module, deserialized from value's serialized state.
159+
type
160+
The ``torch_module``-wrapped subclass.
155161
"""
156-
cls = type(value)
157162
if cls not in _AUTO_WRAPPED_CLASSES:
158163
wrapped = type(
159164
cls.__name__,
160165
(cls,),
161166
{"forward": lambda self, *args, **kwargs: self.call(*args, **kwargs)},
162167
)
163168
_AUTO_WRAPPED_CLASSES[cls] = torch_module(wrapped)
164-
wrapped_cls = _AUTO_WRAPPED_CLASSES[cls]
169+
return _AUTO_WRAPPED_CLASSES[cls]
170+
171+
172+
def _auto_wrap_native_op(value: NativeOP) -> torch.nn.Module:
173+
"""Auto-wrap any NativeOP as a torch.nn.Module via ``torch_module``.
174+
175+
Parameters
176+
----------
177+
value : NativeOP
178+
The dpmodel object to wrap.
179+
180+
Returns
181+
-------
182+
torch.nn.Module
183+
The wrapped pt_expt module, deserialized from value's serialized state.
184+
"""
185+
cls = type(value)
186+
wrapped_cls = auto_wrapped_class(cls)
165187
if not (hasattr(value, "serialize") and hasattr(wrapped_cls, "deserialize")):
166188
raise TypeError(
167189
f"Cannot auto-wrap {cls.__name__}: "

deepmd/pt_expt/model/get_model.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@
99
import copy
1010
import logging
1111

12-
from deepmd.dpmodel.atomic_model.dp_atomic_model import (
13-
DPAtomicModel,
12+
from deepmd.dpmodel.atomic_model.dp_atomic_model import DPAtomicModel as DPAtomicModelDP
13+
from deepmd.dpmodel.atomic_model.linear_atomic_model import (
14+
LinearEnergyAtomicModel as LinearEnergyAtomicModelDP,
1415
)
1516
from deepmd.dpmodel.atomic_model.pairtab_atomic_model import (
16-
PairTabAtomicModel,
17+
PairTabAtomicModel as PairTabAtomicModelDP,
1718
)
1819
from deepmd.dpmodel.model.model_factory import (
1920
BackendModelFactory,
2021
)
2122
from deepmd.dpmodel.model.model_factory import (
2223
get_spin_model as get_spin_model_from_factory,
2324
)
25+
from deepmd.pt_expt.common import (
26+
auto_wrapped_class,
27+
)
2428
from deepmd.pt_expt.descriptor import (
2529
BaseDescriptor,
2630
)
@@ -56,6 +60,12 @@
5660
_WARNED_ONCE: set[str] = set()
5761

5862

63+
# wrapped atomic classes: constructed directly so live children keep their
64+
# runtime state (see the auto_wrapped_class invariant)
65+
DPAtomicModel = auto_wrapped_class(DPAtomicModelDP)
66+
PairTabAtomicModel = auto_wrapped_class(PairTabAtomicModelDP)
67+
LinearEnergyAtomicModel = auto_wrapped_class(LinearEnergyAtomicModelDP)
68+
5969
_model_factory = BackendModelFactory(
6070
descriptor_base=BaseDescriptor,
6171
fitting_base=BaseFitting,
@@ -64,6 +74,7 @@
6474
atomic_model=DPAtomicModel,
6575
pairtab_model=PairTabAtomicModel,
6676
zbl_model=DPZBLModel,
77+
linear_atomic_model=LinearEnergyAtomicModel,
6778
)
6879
get_zbl_model = _model_factory.get_zbl_model
6980

deepmd/pt_expt/model/make_model.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
cuda_infer_level,
2929
)
3030
from deepmd.pt_expt.common import (
31+
auto_wrapped_class,
3132
torch_module,
3233
)
3334
from deepmd.pt_expt.utils.graph_builder import (
@@ -465,7 +466,9 @@ def make_model(
465466
The model.
466467
467468
"""
468-
DPModel = make_model_dp(T_AtomicModel)
469+
# wrapped atomic class: live descriptor/fitting keep their runtime
470+
# state (see the auto_wrapped_class invariant)
471+
DPModel = make_model_dp(auto_wrapped_class(T_AtomicModel))
469472

470473
@torch_module
471474
class CM(DPModel, *T_Bases):

source/tests/common/dpmodel/test_descrpt_dpa4.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,19 @@ def test_supported_feature_roundtrip(self, overrides) -> None:
265265
out2 = np.asarray(dd2.call(coord.reshape(nf, -1), atype, nlist)[0])
266266
np.testing.assert_array_equal(out1, out2)
267267

268+
def test_use_amp_stays_out_of_the_portable_record(self) -> None:
269+
"""``use_amp`` is a runtime/training policy, not model state.
270+
271+
The portable serialization must not carry it (a ``use_amp: true``
272+
record would e.g. be rejected by the JAX deserializer); a fresh
273+
deserialize falls back to the constructor default. Construction-time
274+
survival is pinned at the pt_expt assembly boundary instead
275+
(``test_get_model_dpa4.py``).
276+
"""
277+
dd = make_descriptor(use_amp=False)
278+
assert dd.use_amp is False
279+
assert "use_amp" not in dd.serialize()["config"]
280+
268281
def test_legacy_spin_gate_is_squared_on_deserialize(self) -> None:
269282
"""Version 1.2 stores the env-seed spin gate after the quadratic form.
270283

0 commit comments

Comments
 (0)