Skip to content

Commit 5082854

Browse files
wanghan-iapcmHan Wangpre-commit-ci[bot]
authored
feat(pt_expt): dpa1(attn_layer=0) graph-native NeighborGraph forward (#5583)
## Summary Adds the graph-native forward path for `dpa1(attn_layer=0)` (the factorizable, mixed-types case), built on the `NeighborGraph` foundation from #5581. Geometry enters the descriptor only through per-edge `edge_vec`; the neighbor-axis reduction becomes a `segment_sum` over edge centers. For `pt_expt` this becomes the **default** forward (force/virial via a single autograd backward through `edge_vec`). ## What it adds - **dpmodel**: `edge_env_mat` (per-edge env-mat 4-vector), `DescrptBlockSeAtten._call_graph` + `DescrptDPA1.call_graph`, model `call_lower_graph` (energy), `neighbor_graph_from_ijs` + an optional **ASE** O(N) carry-all builder. - **pt_expt**: `edge_energy_deriv` (autograd `grad(E, edge_vec)` → `edge_force_virial`) + `forward_common_lower_graph` (energy + force + virial + atom_virial). - The dense `DescrptDPA1.call` becomes a thin adapter (`from_dense_quartet → call_graph`) preserving the 5-tuple ABI; a **shape-static** converter keeps it `jax.jit` / `torch.export`-traceable. ## Default behavior - **pt_expt** defaults graph-eligible `dpa1(attn_layer=0, concat tebd, no exclude_types)` models to the carry-all graph (it has the autograd force/virial path). - **dpmodel/jax** keep the dense default (they compute force/virial analytically; the graph lower is energy-only), and **agree with pt_expt at non-binding `sel`**. - Ineligible configs (attention, strip tebd, `exclude_types`, linear/ZBL) fall back to the dense path unchanged. `neighbor_graph_method="legacy"` forces dense; `"dense"`/`"ase"` force the graph. ## Parity (graph vs legacy dense lower, fp64 CPU) | | energy | force | virial | atom_virial | |---|---|---|---|---| | max abs diff | 0 | ~1e-19 | ~1e-18 | **~1e-18** | atom_virial matches the canonical TF==pt-legacy full-to-src convention. dpa1 descriptor + model consistency suites green across dp/jax/pt_expt. ## Known limitations - Default-flip is **pt_expt-only**; full carry-all default for dp/jax needs analytical/jax graph force (follow-up). - `make_fx` (forward + grad) traces; **full `.pt2` AOTI export is a follow-up** (PR-B). The carry-all builders (`build_neighbor_graph`/`from_ijs`) still use `nonzero` (eager-only); their static variants land with the export PR. - Single-rank only; CUDA unvalidated (CPU box); ASE is opt-in O(N) (vesin O(N) is a follow-up); no jax graph force / dpa2-3 message-passing yet. Also folds in three follow-up fixes to the #5581 foundation from @OutisLi's review (dangling spec refs → design discussion, `edge_force_virial` jax int-sum short-circuit, `Array` typing). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added graph-native “lowering” for DPA1 when compatible, including graph-native descriptor/forward execution and graph-native descriptor→model output conversion. * Introduced opt-in `neighbor_graph_method` routing for energy/force/virial, with carry-all neighbor graphs and graph-output fitting/post-processing. * Added new neighbor-graph utilities (including ASE-based carry-all building, `(i,j,S)` conversion, and per-edge environment-matrix computation), exported as part of the public API. * **Bug Fixes** * Improved stability for masked/padded edges, virtual atom handling, and parameter protection consistency; refined traced virial assembly when node-capacity is used. * **Tests** * Expanded parity/regression suites for graph lowering, energy/force/virial, conversion correctness, ragged graphs, and FX tracing. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- 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 ac8e430 commit 5082854

37 files changed

Lines changed: 4388 additions & 98 deletions

deepmd/dpmodel/atomic_model/base_atomic_model.py

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
import dataclasses
23
import functools
34
import math
45
from collections.abc import (
56
Callable,
67
)
78
from typing import (
9+
TYPE_CHECKING,
810
Any,
911
)
1012

13+
if TYPE_CHECKING:
14+
from deepmd.dpmodel.utils.neighbor_graph import (
15+
NeighborGraph,
16+
)
17+
1118
import array_api_compat
1219
import numpy as np
1320

@@ -303,23 +310,110 @@ def forward_common_atomic(
303310
comm_dict=comm_dict,
304311
charge_spin=charge_spin,
305312
)
306-
ret_dict = self.apply_out_stat(ret_dict, atype)
307-
308-
# nf x nloc
309313
atom_mask = xp_take_first_n(ext_atom_mask, 1, nloc)
314+
return self._finalize_atomic_ret(ret_dict, atom_mask, atype)
315+
316+
def forward_common_atomic_graph(
317+
self,
318+
graph: "NeighborGraph",
319+
atype: Array,
320+
fparam: Array | None = None,
321+
aparam: Array | None = None,
322+
charge_spin: Array | None = None,
323+
) -> dict:
324+
"""Graph analogue of :meth:`forward_common_atomic` on the flat node axis.
325+
326+
The node axis is flat ``(N,)`` (``N = sum(graph.n_node)``); masking and
327+
out-stat operate per node. Reuses :meth:`_finalize_atomic_ret`, so
328+
virtual-atom masking, ``atom_excl`` and ``apply_out_stat`` match the dense
329+
path. Model-level ``pair_exclude_types`` is graph-native: when
330+
``self.pair_excl is not None``, an edge-keep mask is ANDed into
331+
``graph.edge_mask`` before the descriptor forward, so excluded type-pairs
332+
contribute zero to the segment_sum. Descriptor-level ``exclude_types`` is
333+
gated by ``uses_graph_lower()==False``.
334+
335+
Parameters
336+
----------
337+
graph
338+
neighbor graph for the local atoms (ghost-free)
339+
atype
340+
flat local atom types. N
341+
fparam
342+
frame parameter. nf x ndf
343+
aparam
344+
atomic parameter. N x nda
345+
charge_spin
346+
charge/spin conditioning. Unused by the dpa1 graph path; accepted so
347+
the interface stays stable for charge/spin-conditioned descriptors.
348+
349+
Returns
350+
-------
351+
result_dict
352+
the result dict on the flat node axis, defined by the `FittingOutputDef`.
353+
354+
"""
355+
xp = array_api_compat.array_namespace(graph.edge_vec)
356+
atype = xp.asarray(atype, device=array_api_compat.device(graph.edge_vec))
357+
atom_mask = self.make_atom_mask(atype) # (N,) bool
358+
atype_clamped = xp.where(atom_mask, atype, xp.zeros_like(atype))
359+
if self.pair_excl is not None:
360+
keep = self.pair_excl.build_edge_exclude_mask(
361+
graph.edge_index, atype_clamped
362+
)
363+
graph = dataclasses.replace(
364+
graph,
365+
edge_mask=graph.edge_mask * xp.astype(keep, graph.edge_mask.dtype),
366+
)
367+
ret_dict = self.forward_atomic_graph(
368+
graph,
369+
atype_clamped,
370+
fparam=fparam,
371+
aparam=aparam,
372+
charge_spin=charge_spin,
373+
)
374+
return self._finalize_atomic_ret(ret_dict, atom_mask, atype)
375+
376+
def _finalize_atomic_ret(
377+
self, ret_dict: dict, atom_mask: Array, atype: Array
378+
) -> dict:
379+
"""Apply out-stat, atom exclusion and virtual-atom zeroing; set ``mask``.
380+
381+
Shared by the dense (:meth:`forward_common_atomic`, ``(nf, nloc)`` leading
382+
dims) and graph (:meth:`forward_common_atomic_graph`, flat ``(N,)`` leading
383+
dim) wrappers -- leading-dim-agnostic.
384+
385+
Parameters
386+
----------
387+
ret_dict
388+
the raw per-atom result dict from ``forward_atomic``/``forward_atomic_graph``
389+
atom_mask
390+
the real-atom mask, True for real and False for virtual atoms. leading dims
391+
atype
392+
the local atom types, used for out-stat and ``atom_excl``. leading dims
393+
394+
Returns
395+
-------
396+
result_dict
397+
``ret_dict`` with out-stat applied, virtual and excluded atoms zeroed,
398+
and the integer ``mask`` key set.
399+
400+
"""
401+
xp = array_api_compat.array_namespace(atype)
402+
ret_dict = self.apply_out_stat(ret_dict, atype)
310403
if self.atom_excl is not None:
311404
atom_mask = xp.logical_and(
312405
atom_mask, self.atom_excl.build_type_exclude_mask(atype)
313406
)
314-
407+
lead = atom_mask.shape # (nf, nloc) dense | (N,) graph
315408
for kk in ret_dict.keys():
316-
out_shape = ret_dict[kk].shape
317-
out_shape2 = math.prod(out_shape[2:])
318-
tmp_arr = ret_dict[kk].reshape([out_shape[0], out_shape[1], out_shape2])
319-
tmp_arr = xp.where(atom_mask[:, :, None], tmp_arr, xp.zeros_like(tmp_arr))
320-
ret_dict[kk] = xp.reshape(tmp_arr, out_shape)
409+
out = ret_dict[kk]
410+
# explicit trailing product (NOT -1): a zero-atom forward (nloc==0)
411+
# has size 0, and numpy cannot infer -1 for a size-0 array.
412+
trail = math.prod(out.shape[len(lead) :])
413+
flat = xp.reshape(out, (*lead, trail))
414+
flat = xp.where(atom_mask[..., None], flat, xp.zeros_like(flat))
415+
ret_dict[kk] = xp.reshape(flat, out.shape)
321416
ret_dict["mask"] = xp.astype(atom_mask, xp.int32)
322-
323417
return ret_dict
324418

325419
def call(

deepmd/dpmodel/atomic_model/dp_atomic_model.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@
33
Callable,
44
)
55
from typing import (
6+
TYPE_CHECKING,
67
Any,
78
)
89

10+
if TYPE_CHECKING:
11+
from deepmd.dpmodel.utils.neighbor_graph import (
12+
NeighborGraph,
13+
)
14+
915
from deepmd.dpmodel.array_api import (
1016
Array,
1117
xp_take_first_n,
@@ -248,6 +254,60 @@ def forward_atomic(
248254
)
249255
return ret
250256

257+
def forward_atomic_graph(
258+
self,
259+
graph: "NeighborGraph",
260+
atype: Array,
261+
fparam: Array | None = None,
262+
aparam: Array | None = None,
263+
charge_spin: Array | None = None,
264+
) -> dict[str, Array]:
265+
"""Graph analogue of :meth:`forward_atomic` on the flat node axis.
266+
267+
Runs the descriptor ``call_graph`` then the fitting ``call_graph`` PER NODE
268+
and returns the raw fitting dict on the flat ``(N, *)`` axis (no reduction
269+
or masking; the wrapper handles those). ``fparam`` is gathered to nodes by
270+
``frame_id`` so each node sees its frame's parameter.
271+
272+
Parameters
273+
----------
274+
graph
275+
neighbor graph for the local atoms (ghost-free)
276+
atype
277+
flat local atom types. N
278+
fparam
279+
frame parameter. nf x ndf
280+
aparam
281+
atomic parameter. N x nda
282+
charge_spin
283+
charge/spin conditioning. Unused by the dpa1 graph path; accepted so
284+
the interface stays stable for charge/spin-conditioned descriptors.
285+
286+
Returns
287+
-------
288+
result_dict
289+
the result dict on the flat node axis, defined by the `FittingOutputDef`.
290+
291+
"""
292+
import array_api_compat
293+
294+
from deepmd.dpmodel.utils.neighbor_graph import (
295+
frame_id_from_n_node,
296+
)
297+
298+
xp = array_api_compat.array_namespace(graph.edge_vec)
299+
type_embedding = self.descriptor.type_embedding.call()
300+
gg, rot_mat = self.descriptor.call_graph(
301+
graph, atype, type_embedding=type_embedding
302+
)
303+
fparam_node = None
304+
if fparam is not None:
305+
frame_id = frame_id_from_n_node(graph.n_node)
306+
fparam_node = xp.take(fparam, frame_id, axis=0) # (N, ndf)
307+
return self.fitting_net.call_graph(
308+
gg, atype, gr=rot_mat, g2=None, h2=None, fparam=fparam_node, aparam=aparam
309+
)
310+
251311
def compute_or_load_stat(
252312
self,
253313
sampled_func: Callable[[], list[dict]],

deepmd/dpmodel/atomic_model/polar_atomic_model.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ def apply_out_stat(
4646
out_bias, out_std = self._fetch_out_stat(self.bias_keys)
4747

4848
if self.fitting_net.shift_diag:
49-
nframes, nloc = atype.shape
5049
dtype = out_bias[self.bias_keys[0]].dtype
5150
device = array_api_compat.device(out_bias[self.bias_keys[0]])
5251
for kk in self.bias_keys:
@@ -57,16 +56,17 @@ def apply_out_stat(
5756
)
5857
modified_bias = temp[atype]
5958

60-
# (nframes, nloc, 1)
59+
# (..., 1) -- (nframes, nloc, 1) or (N, 1)
6160
modified_bias = (
6261
modified_bias[..., xp.newaxis] * (self.fitting_net.scale[atype])
6362
)
6463

6564
eye = xp.eye(3, dtype=dtype, device=device)
66-
eye = xp.tile(eye, (nframes, nloc, 1, 1))
67-
# (nframes, nloc, 3, 3)
65+
# leading-dim-agnostic: (nf, nloc) dense or (N,) flat graph path
66+
eye = xp.tile(eye, (*atype.shape, 1, 1))
67+
# (..., 3, 3)
6868
modified_bias = modified_bias[..., xp.newaxis] * eye
6969

70-
# nf x nloc x odims, out_bias: ntypes x odims
70+
# nf x nloc x odims (rect) or N x odims (flat), out_bias: ntypes x odims
7171
ret[kk] = ret[kk] + modified_bias
7272
return ret

0 commit comments

Comments
 (0)