Skip to content

Commit bdb4007

Browse files
feat(pt-expt): add compact descriptor DPA4C 🎉🎉🎉 (deepmodeling#5972)
## Summary This PR introduces DPA4C, the compact and compressible degree-wise member of the DPA4 family, as a PyTorch Exportable (`pt_expt`) descriptor. DPA4C is a strictly local, one-hop model intended for high-throughput molecular dynamics: it reads each directed neighbor edge once, performs one destination reduction, and converts the resulting degree-wise moments into a fixed invariant vector without cross-atom message passing. The PR includes the complete path from training to deployment: - a backend-neutral DPA4C descriptor and a native `pt_expt` implementation; - graph-native training, serialization, export, compression, and calibration; - fused CUDA descriptor, fitting, force, virial, and magnetic-force paths; - native-spin conditioning from the descriptor through Python, C, C++, and LAMMPS/Kokkos interfaces; - frame-level charge and spin-multiplicity conditioning, including runtime re-specialization of compressed artifacts; - function-preserving fine-tuning from a spin-free checkpoint; - ragged mixed-size training batches without exposing phantom atoms to the network; and - user documentation plus non-spin and native-spin examples. ## Why DPA4C DPA4/SeZM uses equivariant message passing to target the accuracy frontier. DPA4C targets a different operating point: a compact local student whose radial dependence can be tabulated and whose angular computation can be fused into bounded per-edge and per-node CUDA kernels. The descriptor consumes a carry-all cutoff graph rather than a fixed-capacity neighbor list. It therefore has no `sel` parameter, no capacity derived from the densest training frame, and no neighbor truncation. Its persistent per-atom state is determined by `channels` and `lmax`, not by the number of neighbors. ## Descriptor architecture ### Edge representation For every directed edge `j -> i`, DPA4C combines: - the DPA4 Bessel or Gaussian radial basis; - a bias-free one-hidden-layer SwiGLU radial network; - ordered PairFiLM scale and shift terms for `(type_i, type_j)`; - optional pair-conditioned shared radial modes; and - a C3 cutoff envelope whose value and first three radial derivatives join continuously to zero at `rcut`. `radial_modes` increases chemical/radial resolution without widening the per-atom moment state. The portable implementation accepts any non-negative mode count; the compressed CUDA path specializes the production profiles listed below. ### One-reduction degree-wise moments The edge direction is expanded in real Cartesian harmonics through `lmax`. All scalar masses and all angular moments are packed into one edge payload and accumulated with one destination segment reduction. Two smooth neighborhood masses normalize the scalar and non-scalar blocks and are also emitted as descriptor coordinates so the fitting network retains effective coordination information. The channel schedule keeps degree 0 wide, retains several channels for degrees 1 and 2, and uses one channel for degrees 3 and 4. This bounds the node state while preserving the low-degree angular information that dominates the model. ### Fixed invariant readout The node-local readout combines: - exact aligned Gram matrices within each degree; - normalized low-rank bispectrum contractions across allowed degree triples; - the projected `Qv` quartic; and - the two neighborhood-mass coordinates. Only O(3)-even invariant scalars reach the standard energy fitting network. Energy is therefore invariant under rotations, reflections, and neighbor permutations, while force and virial remain conservative derivatives of the same total energy. The public structural controls are: - `channels` in `{8, 16, 32, 64, 128}`; - `lmax` in `{2, 3, 4}`; - `basis_type` in `{bessel, gaussian}`; - `n_radial`; - `radial_modes`; and - `use_amp`, which applies bf16 autocast only to the edge-dominated stage and restores descriptor precision before reduction and invariant contraction. ## Frame charge-state conditioning When `add_chg_spin_ebd` is enabled, DPA4C accepts one frame-level `[charge, multiplicity]` condition. This condition is independent of the per-atom native-spin vector. It enters at two finite locations: 1. a shift of the center type embedding; and 2. a bias of the ordered-pair encoder hidden state. The portable graph path keeps the condition per frame, so one batch may contain different charge states. `default_chg_spin` supplies the fallback state when an input does not provide one. Compression folds a single state into the finite type table and ordered-pair caches, leaving the radial table, angular equations, and CUDA kernel layout unchanged. The exported artifact carries a charge-state fold that rebuilds only the affected constants when the evaluator, C/C++ API, or LAMMPS pair style selects another state. This keeps the compact canonical inference ABI free of a per-edge runtime condition while avoiding a permanently baked-in charge state. ## Compression and deployment Compression tabulates the distance-only radial network with quintic Hermite splines on `[0, rcut]` and snapshots the finite ordered-type-pair tables. The compiled descriptor supports: ```text channels in {8, 16, 32, 64, 128} lmax in {2, 3, 4} radial_modes in {0, 2, 4, 8} precision = float32 ``` The fused implementation includes forward and backward descriptor operators, compact canonical graph operators, fitting-network kernels, and force/virial assembly. The backward saves the minimum node moment state and recomputes the edge-local radial and angular terms, avoiding a persistent per-edge moment tensor. Evaluation is tiled so temporary memory stays bounded for large edge sets. `DP_CUDA_INFER=1` enables the fused descriptor/fitting path with autograd force assembly. `DP_CUDA_INFER=2` additionally uses the compact canonical fused energy/force/virial composition. The export metadata records the graph ABI and dtype contract used by the C++ and LAMMPS loaders. Graph folding now fails explicitly when a topology requests local-owner folding but does not provide a valid owner for every ghost. This prevents a malformed standalone C++ call from silently dropping halo-edge contributions. Extended multi-rank paths keep ghosts as distinct nodes and use reverse communication as their force-folding contract. ## Integration surface - Registers `descriptor.type: dpa4c` for the PyTorch Exportable backend and documents its arguments in `argcheck`. - Adds model serialization, graph export, compression routing, inference metadata, and evaluation inputs for both charge state and native spin. - Extends C and C++ energy/spin interfaces with charge-state dimensions, setters, and per-call inputs. - Adds non-spin water and native-spin NiO examples and a full user guide. - Adds backend-neutral, PyTorch, CUDA, graph-lower, export, fine-tuning, symmetry, derivative, serialization, compression, and deployment tests. - Adapts the DPA1 shared graph-kernel helpers without changing DPA1's public descriptor contract. The final integration commit also replaces the removed `doc_only_pt_expt_supported` symbol with the current `supported_backends("pt_expt")` registry introduced on `master` by deepmodeling#5929. This is the only modification made after cherry-picking the four DPA4C commits. ## Current scope and limitations - DPA4C is implemented for `pt_expt`; other backends are not added here. - Compressed inference is float32-only and restricted to the structural profiles listed above. Unsupported profiles continue to use the portable path or are rejected by explicit compression validation. - Descriptor-level excluded type pairs are not supported by the fused compact kernel. - Native spin requires `scheme: native`; the virtual-atom `deepspin` scheme is not used by DPA4C. - The symmetric spin invariant basis does not represent the antisymmetric Dzyaloshinskii-Moriya interaction. - The provided LAMMPS example covers evaluation and spin minimization. Spin dynamics through stock `fix nve/spin` additionally depends on that fix recognizing the new pair style. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added the DPA4C descriptor with native-spin, charge-state conditioning, compressed CUDA inference, and canonical graph support. * Added native-spin LAMMPS pair styles and expanded C/C++ APIs for spin, charge-state configuration, and GPU graph inference. * Added compression capability detection and support for analytically bounded compression domains. * **Bug Fixes** * Improved force, virial, magnetic-force, charge-state, and loss handling consistency. * **Documentation** * Added DPA4C guides, training configurations, and spin-enabled LAMMPS examples. * **Tests** * Expanded coverage for DPA4C, CUDA compression, export, validation, spin, and charge-state behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ced0016 commit bdb4007

140 files changed

Lines changed: 28393 additions & 1604 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

deepmd/dpmodel/atomic_model/dp_atomic_model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def __init__(
119119
self.fitting_net = fitting
120120
self.fitting_net.reinit_exclude(self.atom_exclude_types)
121121
self.type_map = type_map
122-
self.add_chg_spin_ebd: bool = self.descriptor.get_dim_chg_spin() > 0
122+
self.add_chg_spin_ebd: bool = self.descriptor.has_chg_spin_ebd()
123123
# Structural capability: only descriptors with a native spin
124124
# conditioning mechanism (currently DPA4) accept a ``spin`` kwarg on
125125
# ``call_graph`` at all -- unlike ``charge_spin``, which every
@@ -182,6 +182,10 @@ def supports_graph_export(self) -> bool:
182182
"""Delegates to this model's own descriptor."""
183183
return bool(self.descriptor.supports_graph_export())
184184

185+
def compression_needs_min_nbor_dist(self) -> bool:
186+
"""Delegates to this model's own descriptor."""
187+
return bool(self.descriptor.compression_needs_min_nbor_dist())
188+
185189
def supports_native_spin(self) -> bool:
186190
"""Delegates to this model's own descriptor (cached at construction)."""
187191
return self._supports_native_spin

deepmd/dpmodel/atomic_model/linear_atomic_model.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,15 @@ def enable_compression(
303303
check_frequency,
304304
)
305305

306+
def compression_needs_min_nbor_dist(self) -> bool:
307+
"""Required as soon as ANY child consumes it.
308+
309+
The statistic is measured once and handed to every child, so a single
310+
child that tabulates from the shortest observed distance keeps the
311+
neighbor-statistics pass for the whole composition.
312+
"""
313+
return any(m.compression_needs_min_nbor_dist() for m in self.models)
314+
306315
def uses_graph_lower(self) -> bool:
307316
"""Graph-capable iff EVERY child supports the graph lower.
308317

deepmd/dpmodel/atomic_model/make_base_atomic_model.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,17 @@ def enable_compression(
190190
"""
191191
raise NotImplementedError("This atomi model doesn't support compression!")
192192

193+
def compression_needs_min_nbor_dist(self) -> bool:
194+
"""Whether :meth:`enable_compression` consumes ``min_nbor_dist``.
195+
196+
Returns
197+
-------
198+
bool
199+
Concrete default ``True``, so a model that does not report
200+
otherwise keeps the neighbor-statistics pass.
201+
"""
202+
return True
203+
193204
def make_atom_mask(
194205
self,
195206
atype: t_tensor,

deepmd/dpmodel/atomic_model/pairtab_atomic_model.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,3 +505,14 @@ def enable_compression(
505505
) -> None:
506506
"""Pairtab model does not support compression."""
507507
pass
508+
509+
def compression_needs_min_nbor_dist(self) -> bool:
510+
"""Return whether compression consumes the minimum neighbor distance.
511+
512+
Returns
513+
-------
514+
bool
515+
Always ``False``. The tabulated pair potential carries its own
516+
domain, so compression is a no-op here.
517+
"""
518+
return False

deepmd/dpmodel/descriptor/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from .dpa4 import (
1212
DescrptDPA4,
1313
)
14+
from .dpa4c import (
15+
DescrptDPA4C,
16+
)
1417
from .hybrid import (
1518
DescrptHybrid,
1619
)
@@ -38,6 +41,7 @@
3841
"DescrptDPA2",
3942
"DescrptDPA3",
4043
"DescrptDPA4",
44+
"DescrptDPA4C",
4145
"DescrptHybrid",
4246
"DescrptSeA",
4347
"DescrptSeAttenV2",

deepmd/dpmodel/descriptor/dpa3.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
from deepmd.dpmodel.utils.update_sel import (
3434
UpdateSel,
3535
)
36+
from deepmd.utils.charge_state import (
37+
CHARGE_OFFSET,
38+
CHARGE_TABLE_ROWS,
39+
MULTIPLICITY_TABLE_ROWS,
40+
validate_charge_state,
41+
)
3642
from deepmd.utils.data_system import (
3743
DeepmdDataSystem,
3844
)
@@ -468,11 +474,11 @@ def init_subclass_params(sub_data: dict | Any, sub_class: type) -> Any:
468474

469475
self.use_econf_tebd = use_econf_tebd
470476
self.add_chg_spin_ebd = add_chg_spin_ebd
471-
if default_chg_spin is not None and len(default_chg_spin) != 2:
472-
raise ValueError(
473-
"default_chg_spin must have exactly 2 values [charge, spin]"
474-
)
475-
self.default_chg_spin = default_chg_spin
477+
self.default_chg_spin = (
478+
None
479+
if default_chg_spin is None
480+
else validate_charge_state(default_chg_spin)
481+
)
476482
self.use_tebd_bias = use_tebd_bias
477483
self.use_loc_mapping = use_loc_mapping
478484
self.type_map = type_map
@@ -494,18 +500,16 @@ def init_subclass_params(sub_data: dict | Any, sub_class: type) -> Any:
494500

495501
if self.add_chg_spin_ebd:
496502
self.cs_activation_fn = get_activation_fn(activation_function)
497-
# -100 ~ 100 is a conservative bound
498503
self.chg_embedding = TypeEmbedNet(
499-
ntypes=200,
504+
ntypes=CHARGE_TABLE_ROWS,
500505
neuron=[self.tebd_dim],
501506
padding=True,
502507
activation_function="Linear",
503508
precision=precision,
504509
seed=child_seed(seed, 3),
505510
)
506-
# 100 is a conservative upper bound
507511
self.spin_embedding = TypeEmbedNet(
508-
ntypes=100,
512+
ntypes=MULTIPLICITY_TABLE_ROWS,
509513
neuron=[self.tebd_dim],
510514
padding=True,
511515
activation_function="Linear",
@@ -543,6 +547,10 @@ def get_dim_chg_spin(self) -> int:
543547
"""Returns the dimension of charge_spin input."""
544548
return 2 if self.add_chg_spin_ebd else 0
545549

550+
def has_chg_spin_ebd(self) -> bool:
551+
"""Return whether a frame charge/spin condition is configured."""
552+
return self.add_chg_spin_ebd
553+
546554
def get_default_chg_spin(self) -> list[float] | None:
547555
"""Returns the default charge_spin values."""
548556
return self.default_chg_spin
@@ -755,7 +763,7 @@ def call(
755763
assert self.spin_embedding is not None
756764
chg_tebd = self.chg_embedding.call()
757765
spin_tebd = self.spin_embedding.call()
758-
charge = xp.astype(charge_spin[:, 0], xp.int64) + 100
766+
charge = xp.astype(charge_spin[:, 0], xp.int64) + CHARGE_OFFSET
759767
spin = xp.astype(charge_spin[:, 1], xp.int64)
760768
chg_ebd = xp.reshape(
761769
xp.take(chg_tebd, xp.reshape(charge, (-1,)), axis=0),

deepmd/dpmodel/descriptor/dpa4.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@
7373
from deepmd.dpmodel.utils.update_sel import (
7474
UpdateSel,
7575
)
76+
from deepmd.utils.charge_state import (
77+
validate_charge_state,
78+
)
7679
from deepmd.utils.version import (
7780
check_version_compatibility,
7881
)
@@ -784,10 +787,10 @@ def __init__(
784787
self.edge_cartesian = bool(edge_cartesian)
785788
self.node_cartesian = str(node_cartesian)
786789
self.add_chg_spin_ebd = bool(add_chg_spin_ebd)
787-
if default_chg_spin is not None and len(default_chg_spin) != 2:
788-
raise ValueError("`default_chg_spin` must contain [charge, spin].")
789790
self.default_chg_spin = (
790-
None if default_chg_spin is None else [float(x) for x in default_chg_spin]
791+
None
792+
if default_chg_spin is None
793+
else validate_charge_state(default_chg_spin)
791794
)
792795

793796
# === Native per-atom spin embedding ===
@@ -2282,6 +2285,10 @@ def get_ntypes(self) -> int:
22822285
def get_type_map(self) -> list[str]:
22832286
return self.type_map if self.type_map is not None else []
22842287

2288+
def has_chg_spin_ebd(self) -> bool:
2289+
"""Return whether a frame charge/spin condition is configured."""
2290+
return self.charge_spin_embedding is not None
2291+
22852292
def get_dim_chg_spin(self) -> int:
22862293
"""Return the charge/spin condition width."""
22872294
return 2 if self.add_chg_spin_ebd else 0

deepmd/dpmodel/descriptor/dpa4_nn/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@
7373
merge_lora_into_base,
7474
strip_lora_from_extra_state,
7575
)
76+
from .mlp import (
77+
SwiGLUMLP,
78+
resolve_swiglu_hidden_width,
79+
)
7680
from .norm import (
7781
EquivariantRMSNorm,
7882
ReducedEquivariantRMSNorm,
@@ -159,6 +163,7 @@
159163
"SeZMTypeEmbedding",
160164
"SpinEmbedding",
161165
"SwiGLU",
166+
"SwiGLUMLP",
162167
"WignerDCalculator",
163168
"apply_lora_to_sezm",
164169
"build_cartesian_basis",
@@ -189,6 +194,7 @@
189194
"quaternion_z_rotation",
190195
"resolve_s2_grid_resolution",
191196
"resolve_so3_grid",
197+
"resolve_swiglu_hidden_width",
192198
"safe_norm",
193199
"segment_envelope_gated_softmax",
194200
"so3_packed_index",

deepmd/dpmodel/descriptor/dpa4_nn/embedding.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
from deepmd.dpmodel.utils.type_embed import (
4646
remap_atype_to_padding,
4747
)
48+
from deepmd.utils.charge_state import (
49+
CHARGE_OFFSET,
50+
CHARGE_TABLE_ROWS,
51+
MULTIPLICITY_TABLE_ROWS,
52+
)
4853
from deepmd.utils.version import (
4954
check_version_compatibility,
5055
)
@@ -128,26 +133,41 @@ def __init__(
128133
# === Step 2. Register the embedding table parameter ===
129134
self.adam_type_embedding = table
130135

131-
def call(self, atype: Any) -> Any:
136+
def call(self, atype: Any | None = None) -> Any:
132137
"""
133138
Gather type embeddings.
134139
135140
Parameters
136141
----------
137142
atype
138-
Atom types with shape (...,). Valid type range is [0, ntypes-1].
143+
Atom types with shape (...). Valid type range is [0, ntypes-1].
144+
If omitted, return the complete embedding table, including the
145+
optional padding row. This form is used by graph-native descriptor
146+
ABIs that precompute the table once per forward call.
139147
140148
Returns
141149
-------
142150
Array
143-
Type embeddings with shape (..., embed_dim).
151+
Gathered type embeddings with shape ``(..., embed_dim)`` when
152+
``atype`` is provided. Otherwise, the complete table with shape
153+
``(ntypes + int(padding), embed_dim)``.
144154
"""
155+
# === Step 1. Return the complete graph-native lookup table ===
156+
if atype is None:
157+
xp = array_api_compat.array_namespace(self.adam_type_embedding)
158+
return xp_asarray_nodetach(
159+
xp,
160+
self.adam_type_embedding[...],
161+
device=array_api_compat.device(self.adam_type_embedding),
162+
)
163+
164+
# === Step 2. Gather rows for an explicit atom-type tensor ===
145165
xp = array_api_compat.array_namespace(atype)
146166
weight = xp_asarray_nodetach(
147167
xp, self.adam_type_embedding[...], device=array_api_compat.device(atype)
148168
)
149-
# torch.embedding gather: flatten the indices to int64, take the rows,
150-
# then restore the original index shape.
169+
# Flattening provides one backend-neutral gather while preserving every
170+
# leading batch or graph dimension on restoration.
151171
index = xp.astype(xp.reshape(atype, (-1,)), xp.int64)
152172
if self.padding:
153173
index = remap_atype_to_padding(index, self.ntypes + 1)
@@ -869,15 +889,15 @@ def __init__(
869889
raise ValueError("`embed_dim` must be positive")
870890

871891
self.charge_embedding = SeZMTypeEmbedding(
872-
ntypes=200,
892+
ntypes=CHARGE_TABLE_ROWS,
873893
embed_dim=self.embed_dim,
874894
precision=self.precision,
875895
seed=child_seed(seed, 0),
876896
trainable=self.trainable,
877897
padding=False,
878898
)
879899
self.spin_embedding = SeZMTypeEmbedding(
880-
ntypes=100,
900+
ntypes=MULTIPLICITY_TABLE_ROWS,
881901
embed_dim=self.embed_dim,
882902
precision=self.precision,
883903
seed=child_seed(seed, 1),
@@ -908,7 +928,7 @@ def call(self, charge_spin: Any) -> Any:
908928
Mixed condition embedding with shape (nf, embed_dim).
909929
"""
910930
xp = array_api_compat.array_namespace(charge_spin)
911-
charge = xp.astype(charge_spin[:, 0], xp.int64) + 100
931+
charge = xp.astype(charge_spin[:, 0], xp.int64) + CHARGE_OFFSET
912932
spin = xp.astype(charge_spin[:, 1], xp.int64)
913933
charge_embed = self.charge_embedding(charge)
914934
spin_embed = self.spin_embedding(spin)

0 commit comments

Comments
 (0)