Skip to content

Commit 9bd1f16

Browse files
wanghan-iapcmHan Wangpre-commit-ci[bot]
authored
feat(pt_expt): graph-native dpa1 — .pt2 export + compiled training + C++ inference single & multi-rank (NeighborGraph PR-B) (#5604)
## NeighborGraph PR-B — graph `.pt2` export, compiled training, and C++ inference (single & multi-rank) This PR spans the full PR-B: **B1** (Python: graph `.pt2` export + compiled training on the graph lower), **B2** (C++ single-rank inference of the graph `.pt2`, dynamic edge axis), and **B3** (C++/LAMMPS multi-rank). Built on the merged PR-A (#5583). Scope: dpa1, `attn_layer=0`, pt_expt. ### B1 — graph `.pt2` export + compiled training (Python) - `forward_common_lower_graph_exportable` trace target; `serialization.py` graph export branch (`lower_kind="graph"`, `lower_input_kind` metadata); `_eval_model_graph` DeepEval dispatch (parity vs eager dpa1 **1e-10 pbc+nopbc**). - **Compiled training retargeted to the graph lower so eager == compiled** (the MUST-FIX) → `force_legacy_descriptor` deleted. Root cause was a real dpa1 `call_graph` autograd **detach** bug (`xp.asarray(tebd, device=)` drops the tebd-net gradient under torch); fixed. ### B2 — C++ graph ingestion (dynamic edge axis, single-rank) - Graph `.pt2` uses a **dynamic edge axis** (`Dim("nedge", min=2)`) — one artifact evals any system size (proven across 56- and 380-edge systems at 1e-10), no C++ capacity ceiling. - C++ `DeepPotPTExpt`: `lower_input_is_graph_` + `run_model_graph` (NeighborGraph ABI: `atype, n_node, edge_index, edge_vec, edge_mask, …`) + `buildGraphTensors` (mirrors the #5562 edge path; node types from `atype_ext`); `remap_graph_outputs_to_dense_keys` (single-rank). - gtest: 5 cases × {double,float} = 10/10 (build-nlist parity, dynamic-E 2nd size, `ago>0`, tiny system, atomic-overload). The review process caught two bugs that would otherwise have shipped: an `ago>0` heap-OOB (by inspection) and a public-vs-internal output-key mismatch (at runtime). ### B3 — multi-rank C++ / LAMMPS (non-MP) - **dpa1 is non-message-passing ⇒ multi-rank needs NO `border_op`/with-comm artifact** (that is a message-passing concern, deferred to PR-G). Multi-rank reuses the **same single-rank graph `.pt2`**, fed an **extended-region graph** (`buildGraphTensors(fold_to_local=false)`, `N=nall`, ghost node types from `atype_ext` incl. halo), with owned energy = `sum(atom_energy[0:nloc])` and the extended force folded to owners through the **existing dense `select_map` reverse-comm**. The fail-fast for `graph && multi_rank && has_message_passing` is retained. - **Validated locally on multi-CPU** (no GPU needed for correctness): `test_lammps_dpa1_graph_pt2.py` — single-rank vs reference, `mpirun -n 2` ≡ single-rank (energy + per-atom force + virial, atol 1e-8), plus an empty-subdomain (`nloc=0`) corner. Single-rank gtests stay 10/10 (multi-rank is purely additive). Multi-rank matched single-rank on the first run. ### Tests / known limitations - Per-task + whole-phase reviews all Ready-to-merge. - **pt_expt-only; dpa1 (non-MP) only.** Follow-ons: **PR-C** vesin/nv O(N) builders (carry-all builders still use `nonzero`, eager-only), **PR-D** attention, **PR-E** angles, **PR-F** jax graph force, **PR-G** dpa2/3 message-passing (forward halo + with-comm). CUDA multi-rank unvalidated locally. Carried code-cleanup follow-ups: a ~60-line DRY duplication in `training.py`; the multi-rank *atomic* output branch has no direct gtest (covered indirectly by the mpirun per-atom-virial assertion, since a single-process gtest can't set `nprocs>1`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary * **New Features** * Added support for graph-schema (NeighborGraph) model archives with a selectable `lower_kind="graph"` export path, including CLI support and new graph-form inference handling. * Added static edge-capacity support during graph construction. * **Bug Fixes** * Improved gradient continuity for type embeddings in graph mode. * Enhanced trace/export stability by preventing out-of-range graph indices/frame IDs and making scatter/frame sizing more consistent. * **Tests** * Added/extended parity, export metadata, training, and LAMMPS single-/multi-rank validation for graph-form `.pt2`, plus metadata checks for `lower_input_kind`. <!-- 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 0f19772 commit 9bd1f16

25 files changed

Lines changed: 3260 additions & 139 deletions

deepmd/dpmodel/descriptor/dpa1.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -764,9 +764,13 @@ def call_graph(
764764
)
765765
# FLAT node axis (N, ...): no (nf, nloc) reshape -- ragged-native, spec.
766766
if self.concat_output_tebd:
767-
tebd = xp.asarray(type_embedding, device=dev)
767+
# Use type_embedding directly (mirrors the dense path's
768+
# ``xp.take(type_embedding, ...)``): ``xp.asarray(..., device=dev)``
769+
# DETACHES under torch, silently severing the type-embedding weight
770+
# gradient so the tebd net never trains; type_embedding already lives
771+
# on the model device, so the device cast was redundant anyway.
768772
atype_local = xp.asarray(atype, device=dev)
769-
atype_embd = xp.take(tebd, atype_local, axis=0) # (N, tebd_dim)
773+
atype_embd = xp.take(type_embedding, atype_local, axis=0) # (N, tebd_dim)
770774
grrg = xp.concat([grrg, atype_embd], axis=-1)
771775
return grrg, rot_mat
772776

@@ -1748,7 +1752,10 @@ def call_graph(
17481752
ss = rr[:, 0:1] # (E, 1)
17491753
# neighbor / center type embeddings (concat mode); ghost type == owner type
17501754
# so gathering by the LOCAL owner (src) reproduces the dense neighbor tebd.
1751-
tebd = xp.asarray(type_embedding, device=dev)
1755+
# NB: do NOT wrap in ``xp.asarray(..., device=dev)`` -- that DETACHES under
1756+
# torch and severs the type-embedding weight gradient (the tebd net would
1757+
# never train); type_embedding already lives on the model device.
1758+
tebd = type_embedding
17521759
atype_embd_nlist = xp.take(tebd, nei_type, axis=0) # (E, tebd_dim)
17531760
if not self.type_one_side:
17541761
atype_embd_nnei = xp.take(tebd, center_type, axis=0) # (E, tebd_dim)

deepmd/dpmodel/utils/neighbor_graph/derivatives.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,42 @@ def edge_force_virial(
8080
frame via the frame of their ``dst`` node.
8181
"""
8282
xp = array_api_compat.array_namespace(g_e)
83-
# node-axis size; when a static ``node_capacity`` is supplied (the jax/export
84-
# path) short-circuit so we never call int() on the traced ``sum(n_node)``.
85-
n_out = int(node_capacity) if node_capacity is not None else int(xp.sum(n_node))
83+
# node-axis size; when a ``node_capacity`` is supplied (the jax/export path)
84+
# use it AS-IS so we never call int() on the traced ``sum(n_node)`` -- and,
85+
# crucially, never on ``node_capacity`` itself: under symbolic make_fx /
86+
# torch.export it is a SymInt (``atype.shape[0]``); ``int(SymInt)`` would
87+
# SPECIALIZE the node axis to the trace-time sample size, baking a constant
88+
# ``N`` into the scatter and breaking dynamic-``N`` inference.
89+
n_out = node_capacity if node_capacity is not None else int(xp.sum(n_node))
8690
nf = n_node.shape[0]
8791
# zero padding/guard contributions; cast mask to g's dtype (array-API pure,
8892
# CLAUDE.md mask-multiply guideline — avoids bool*float under array_api_strict)
8993
g = g_e * xp.astype(edge_mask[:, None], g_e.dtype)
90-
src = edge_index[0]
91-
dst = edge_index[1]
94+
# Wrap node indices into ``[0, n_out)`` so every scatter address is provably
95+
# in-bounds. For a well-formed graph every real edge already has
96+
# ``index < n_out`` (== ``atype.shape[0]``), so this modulo is the IDENTITY on
97+
# real edges (pinned by test_modulo_clamp_leaves_real_edges_unchanged) -- a
98+
# correctness-preserving guard, not a value fixup.
99+
#
100+
# Why it is needed (root cause, GPU-confirmed): under the dynamic-edge graph
101+
# ``torch.export`` path the node count is traced as several equal-but-distinct
102+
# symbols (``atype.shape[0]``, ``fit_ret.shape[0]``, ...), tied only by
103+
# ``aten._assert_scalar(Eq(...))`` nodes. ``_strip_shape_assertions``
104+
# (pt_expt/utils/serialization.py) neutralises ALL such asserts so export can
105+
# trace -- which also drops those node-count equalities, so inductor can no
106+
# longer prove the scatter index and its bound ``ks0 == n_out`` share a symbol
107+
# and emits ``tl.device_assert(idx < ks0)`` (fatal on CUDA; unchecked on CPU,
108+
# which is why all CPU dev/CI was green). ``% n_out`` discharges that guard
109+
# unconditionally. This is the PERMANENT fix: the upstream alternative --
110+
# making the SHARED, spin-export-critical ``_strip_shape_assertions``
111+
# selective -- risks re-triggering the torch.export bugs it exists to bypass
112+
# and the spin ``.pt2`` path, so it is deliberately NOT taken.
113+
#
114+
# Pure arithmetic => torch.export-safe, unlike ``xp.clip`` (SymInt bound
115+
# breaks array_api_compat's clip) and unlike a mask-multiply (which misses the
116+
# ``edge_mask == 1`` indices the stripped guard mis-bounds).
117+
src = edge_index[0] % n_out
118+
dst = edge_index[1] % n_out
92119
# force (output sized to the node axis, incl. any padding tail)
93120
force = segment_sum(g, dst, n_out) - segment_sum(g, src, n_out)
94121
# per-edge virial w_e[k, j] = -g_e[k] * edge_vec[j] (broadcast, no einsum)
@@ -101,6 +128,8 @@ def edge_force_virial(
101128
boundaries = xp.cumulative_sum(n_node) # (nf,) per-frame node upper bounds
102129
edge_frame = xp.astype(
103130
xp.searchsorted(boundaries, dst, side="right"), xp.int64
104-
) # (E,) in [0, nf)
131+
) # (E,) in [0, nf]
132+
# wrap into [0, nf) for the same CUDA-bounds reason (export-safe modulo)
133+
edge_frame = edge_frame % nf
105134
virial = segment_sum(w_edge, edge_frame, nf) # (nf, 3, 3)
106135
return force, atom_virial, virial

deepmd/dpmodel/utils/neighbor_graph/graph.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,18 @@ def frame_id_from_n_node(n_node: Array, n_total: int | None = None) -> Array:
153153
dev = array_api_compat.device(n_node)
154154
if n_total is None:
155155
n_total = int(xp.sum(n_node))
156-
nf = n_node.shape[0]
157156
idx = xp.arange(n_total, dtype=n_node.dtype, device=dev)
158157
boundaries = xp.cumulative_sum(n_node) # (nf,) upper bounds, exclusive
159158
frame_id = xp.astype(xp.searchsorted(boundaries, idx, side="right"), xp.int64)
160159
# padding nodes (idx >= sum(n_node)) land at frame ``nf`` (OOB); clamp them to
161160
# the last real frame so the per-frame scatter never indexes out of range.
162-
return xp.minimum(frame_id, xp.asarray(nf - 1, dtype=xp.int64, device=dev))
161+
# Derive ``nf - 1`` as a RUNTIME 0-d tensor (sum of ones over the frame axis)
162+
# rather than ``xp.asarray(n_node.shape[0] - 1)``: under symbolic make_fx /
163+
# torch.export, ``shape[0]`` is a SymInt and materializing it into a constant
164+
# tensor SPECIALIZES the frame axis -- baking the trace-time frame count into
165+
# every downstream per-frame reduction and breaking dynamic-``nf`` inference.
166+
last_frame = xp.sum(xp.ones_like(n_node)) - 1 # 0-d int == nf - 1
167+
return xp.minimum(frame_id, xp.astype(last_frame, xp.int64))
163168

164169

165170
def node_validity_mask(n_node: Array, n_total: int) -> Array:

deepmd/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,16 @@ def main_parser() -> argparse.ArgumentParser:
350350
type=str,
351351
help="(Supported backend: PyTorch) Task head (alias: model branch) to freeze if in multi-task mode.",
352352
)
353+
parser_frz.add_argument(
354+
"--lower-kind",
355+
default="nlist",
356+
type=str,
357+
choices=["nlist", "graph"],
358+
help="(Supported backend: PyTorch Exportable) Lower-level export form of the "
359+
"frozen .pt2: 'nlist' (default, dense neighbor-list lower) or 'graph' "
360+
"(NeighborGraph edge-list lower; only for graph-eligible models, currently "
361+
"dpa1 with attn_layer=0). 'graph' selects the C++ graph inference path.",
362+
)
353363

354364
# * test script ********************************************************************
355365
parser_tst = subparsers.add_parser(

deepmd/pt_expt/entrypoints/main.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ def freeze(
387387
model: str,
388388
output: str = "frozen_model.pte",
389389
head: str | None = None,
390+
lower_kind: str = "nlist",
390391
) -> None:
391392
"""Freeze a pt_expt checkpoint into a .pte exported model.
392393
@@ -398,6 +399,13 @@ def freeze(
398399
Path for the output .pte file.
399400
head : str or None
400401
Head to freeze in multi-task mode.
402+
lower_kind : str
403+
Lower-level export form: ``"nlist"`` (default, dense neighbor-list lower)
404+
or ``"graph"`` (NeighborGraph edge-list lower). ``"graph"`` is only valid
405+
for graph-eligible models (``mixed_types`` and ``uses_graph_lower``,
406+
currently dpa1 with ``attn_layer == 0``) and selects the C++ graph
407+
inference path; the per-atom virial is enabled for it (near-free in the
408+
graph path: one extra scatter off the shared single backward).
401409
"""
402410
import torch
403411

@@ -458,12 +466,34 @@ def freeze(
458466
single_model_params = model_params
459467

460468
m.eval()
469+
470+
# The graph lower is opt-in and only valid for graph-eligible models (dpa1
471+
# attn_layer==0 today). Fail fast with a clear message rather than emitting a
472+
# broken .pt2. Enable the per-atom virial for the graph form -- it is
473+
# near-free there (one extra scatter off the single shared backward).
474+
do_atomic_virial = False
475+
if lower_kind == "graph":
476+
from deepmd.pt_expt.train.training import (
477+
_model_uses_graph_lower,
478+
)
479+
480+
if not _model_uses_graph_lower(m):
481+
raise ValueError(
482+
"lower_kind='graph' requires a graph-eligible model "
483+
"(mixed_types and a descriptor exposing uses_graph_lower()==True, "
484+
"currently dpa1 with attn_layer==0). Use lower_kind='nlist' for "
485+
"this model."
486+
)
487+
do_atomic_virial = True
488+
461489
model_dict_serialized = m.serialize()
462490
deserialize_to_file(
463491
output,
464492
{"model": model_dict_serialized, "model_def_script": single_model_params},
493+
do_atomic_virial=do_atomic_virial,
494+
lower_kind=lower_kind,
465495
)
466-
log.info("Saved frozen model to %s", output)
496+
log.info("Saved frozen model to %s (lower_kind=%s)", output, lower_kind)
467497

468498

469499
def change_bias(
@@ -701,9 +731,19 @@ def main(args: list[str] | argparse.Namespace | None = None) -> None:
701731
f"Checkpoint path '{model_path}' does not exist."
702732
)
703733
FLAGS.model = str(model_path)
734+
_lower_kind = getattr(FLAGS, "lower_kind", "nlist")
704735
if not FLAGS.output.endswith((".pte", ".pt2")):
705-
FLAGS.output = str(Path(FLAGS.output).with_suffix(".pte"))
706-
freeze(model=FLAGS.model, output=FLAGS.output, head=FLAGS.head)
736+
# Default suffix: .pt2 for the graph export (an AOTI .pt2 archive is
737+
# what the C++ graph path consumes), .pte otherwise. Explicit user
738+
# .pte / .pt2 suffixes are preserved for both.
739+
_default_suffix = ".pt2" if _lower_kind == "graph" else ".pte"
740+
FLAGS.output = str(Path(FLAGS.output).with_suffix(_default_suffix))
741+
freeze(
742+
model=FLAGS.model,
743+
output=FLAGS.output,
744+
head=FLAGS.head,
745+
lower_kind=_lower_kind,
746+
)
707747
elif FLAGS.command == "change-bias":
708748
change_bias(
709749
input_file=FLAGS.INPUT,

deepmd/pt_expt/infer/deep_eval.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@
6666
import ase.neighborlist
6767

6868

69+
# Public output keys emitted by the graph-form AOTI forward
70+
# (``forward_lower_graph_exportable``) keyed by the output-variable category that
71+
# ``request_defs`` carries. The graph path is LOCAL-only (``N == sum(n_node)``
72+
# nodes, no ghosts), so its outputs are already at local-atom resolution -- no
73+
# ``communicate_extended_output`` fold-back is needed.
74+
_GRAPH_CATEGORY_TO_KEY = {
75+
OutputVariableCategory.OUT: "atom_energy",
76+
OutputVariableCategory.REDU: "energy",
77+
OutputVariableCategory.DERV_R: "force",
78+
OutputVariableCategory.DERV_C_REDU: "virial",
79+
OutputVariableCategory.DERV_C: "atom_virial",
80+
}
81+
82+
6983
def _reshape_charge_spin(
7084
charge_spin: np.ndarray, nframes: int, dim_chg_spin: int
7185
) -> np.ndarray:
@@ -1423,6 +1437,10 @@ def _eval_model(
14231437
request_defs: list[OutputVariableDef],
14241438
charge_spin: np.ndarray | None = None,
14251439
) -> tuple[np.ndarray, ...]:
1440+
if self.metadata.get("lower_input_kind") == "graph":
1441+
return self._eval_model_graph(
1442+
coords, cells, atom_types, fparam, aparam, request_defs, charge_spin
1443+
)
14261444
model_inputs, mapping_t, nframes, natoms = self._prepare_inputs(
14271445
coords, cells, atom_types, fparam, aparam, charge_spin
14281446
)
@@ -1621,6 +1639,101 @@ def _eval_model_spin(
16211639
)
16221640
return tuple(results)
16231641

1642+
def _eval_model_graph(
1643+
self,
1644+
coords: np.ndarray,
1645+
cells: np.ndarray | None,
1646+
atom_types: np.ndarray,
1647+
fparam: np.ndarray | None,
1648+
aparam: np.ndarray | None,
1649+
request_defs: list[OutputVariableDef],
1650+
charge_spin: np.ndarray | None = None,
1651+
) -> tuple[np.ndarray, ...]:
1652+
"""Evaluate a graph-form ``.pt2`` (``lower_input_kind == "graph"``).
1653+
1654+
Builds a carry-all :class:`~deepmd.dpmodel.utils.neighbor_graph.NeighborGraph`
1655+
from the eval system at its exact (tight) edge count and feeds the
1656+
positional schema
1657+
``(atype, n_node, edge_index, edge_vec, edge_mask, fparam, aparam,
1658+
charge_spin)`` to the exported forward. The AOTI artifact's edge axis
1659+
is DYNAMIC (B2.0), so no ``edge_capacity`` padding is needed. The
1660+
forward returns the LOCAL public keys directly, so results are reshaped
1661+
without ``communicate_extended_output``.
1662+
"""
1663+
from deepmd.dpmodel.utils.neighbor_graph import (
1664+
build_neighbor_graph,
1665+
)
1666+
from deepmd.pt_expt.utils.env import (
1667+
DEVICE,
1668+
)
1669+
1670+
nframes = coords.shape[0]
1671+
if len(atom_types.shape) == 1:
1672+
natoms = len(atom_types)
1673+
atom_types = np.tile(atom_types, nframes).reshape(nframes, -1)
1674+
else:
1675+
natoms = len(atom_types[0])
1676+
1677+
coord_input = coords.reshape(nframes, natoms, 3)
1678+
box_input = cells.reshape(nframes, 9) if cells is not None else None
1679+
# Dynamic edge axis (B2.0): build the carry-all graph at its exact edge
1680+
# count (no static padding); the AOTI artifact accepts any E.
1681+
graph = build_neighbor_graph(
1682+
coord_input,
1683+
atom_types,
1684+
box_input,
1685+
self._rcut,
1686+
)
1687+
1688+
atype_t = torch.tensor(
1689+
np.asarray(atom_types).reshape(-1), dtype=torch.int64, device=DEVICE
1690+
)
1691+
n_node_t = torch.tensor(
1692+
np.asarray(graph.n_node), dtype=torch.int64, device=DEVICE
1693+
)
1694+
edge_index_t = torch.tensor(
1695+
np.asarray(graph.edge_index), dtype=torch.int64, device=DEVICE
1696+
)
1697+
edge_vec_t = torch.tensor(
1698+
np.asarray(graph.edge_vec), dtype=torch.float64, device=DEVICE
1699+
)
1700+
edge_mask_t = torch.tensor(
1701+
np.asarray(graph.edge_mask), dtype=torch.bool, device=DEVICE
1702+
)
1703+
1704+
fparam_t, aparam_t = self._prepare_optional_lower_inputs(
1705+
fparam, aparam, nframes, natoms, DEVICE
1706+
)
1707+
charge_spin_t = self._make_charge_spin_input(nframes, charge_spin)
1708+
1709+
model_inputs = (
1710+
atype_t,
1711+
n_node_t,
1712+
edge_index_t,
1713+
edge_vec_t,
1714+
edge_mask_t,
1715+
fparam_t,
1716+
aparam_t,
1717+
charge_spin_t,
1718+
)
1719+
if self._is_pt2:
1720+
model_ret = self._pt2_runner(*model_inputs)
1721+
else:
1722+
model_ret = self.exported_module(*model_inputs)
1723+
1724+
results = []
1725+
for odef in request_defs:
1726+
shape = self._get_output_shape(odef, nframes, natoms)
1727+
gkey = _GRAPH_CATEGORY_TO_KEY.get(odef.category)
1728+
val = model_ret.get(gkey) if gkey is not None else None
1729+
if val is not None:
1730+
results.append(val.detach().cpu().numpy().reshape(shape))
1731+
else:
1732+
results.append(
1733+
np.full(np.abs(shape), np.nan, dtype=GLOBAL_NP_FLOAT_PRECISION)
1734+
)
1735+
return tuple(results)
1736+
16241737
def _get_output_shape(
16251738
self, odef: OutputVariableDef, nframes: int, natoms: int
16261739
) -> list[int]:

0 commit comments

Comments
 (0)