Skip to content

Commit ffbc655

Browse files
author
Han Wang
committed
feat(pt_expt): graph-native Hessian + fix fparam graph export/train (OutisLi P1s)
Two P1s from OutisLi's review of the graph-eligible DPA2 default-flip: 1. fparam graph export/train crash: dp_atomic_model.forward_atomic_graph called frame_id_from_n_node(graph.n_node) without n_total, so it fell back to int(sum(n_node)) -- an int() on a traced tensor that make_fx/torch.export cannot evaluate (GuardOnDataDependentSymNode), breaking both the graph .pt2 export and compiled training for any numb_fparam>0 model. Pass the static flat node count n_total=atype.shape[0]. Regression: test_graph_lower_fparam_symbolic_trace_and_compile (red before, green after). 2. Hessian on the graph route: rather than fall back to dense, the graph route now computes the Hessian NATIVELY. The Hessian is just autograd.functional.hessian of the reduced energy w.r.t. coords -- route- agnostic. Added _WrapperForwardEnergyGraph + _cal_hessian_ext_graph and a Hessian loop in _call_common_graph (parallel to the dense forward_common_ atomic loop), differentiating w.r.t. LOCAL coords by rebuilding the carry-all graph inside the wrapper (no extended nall->nloc fold needed -- the graph reduces over owned nodes). Output matches the dense route bit-tight in the same (nf, nloc*3, nloc*3) layout. Graph-builder dispatch factored into the shared _build_graph_for_method helper (one owner for _call_common_graph and the Hessian wrapper). Regression: test_graph_native_hessian_matches_dense (graph == dense Hessian at 1e-9, plus symmetry). Eager-only, like the dense Hessian. Benefits all graph-eligible descriptors, not just dpa2.
1 parent a266a3b commit ffbc655

3 files changed

Lines changed: 293 additions & 29 deletions

File tree

deepmd/dpmodel/atomic_model/dp_atomic_model.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,12 @@ def forward_atomic_graph(
308308
)
309309
fparam_node = None
310310
if fparam is not None:
311-
frame_id = frame_id_from_n_node(graph.n_node)
311+
# Pass the STATIC flat node count (``atype.shape[0] == N``) so the
312+
# helper does not fall back to ``int(sum(n_node))``: that int() on a
313+
# traced tensor breaks make_fx / torch.export
314+
# (``GuardOnDataDependentSymNode``) for the graph .pt2 export and
315+
# compiled-training paths when ``numb_fparam > 0``.
316+
frame_id = frame_id_from_n_node(graph.n_node, n_total=atype.shape[0])
312317
fparam_node = xp.take(fparam, frame_id, axis=0) # (N, ndf)
313318
return self.fitting_net.call_graph(
314319
gg, atype, gr=rot_mat, g2=None, h2=None, fparam=fparam_node, aparam=aparam

deepmd/pt_expt/model/make_model.py

Lines changed: 173 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,154 @@ def __call__(self, coord_flat: torch.Tensor) -> torch.Tensor:
191191
return energy_redu
192192

193193

194+
def _build_graph_for_method(
195+
method: str,
196+
coord: torch.Tensor,
197+
atype: torch.Tensor,
198+
box: torch.Tensor | None,
199+
rcut: float,
200+
pair_excl: Any,
201+
) -> Any:
202+
"""Build a carry-all ``NeighborGraph`` for the named pt_expt builder.
203+
204+
Single owning site for the graph-builder dispatch shared by
205+
:meth:`_call_common_graph` and the graph Hessian wrapper
206+
(:class:`_WrapperForwardEnergyGraph`), so both build the graph identically.
207+
"""
208+
from deepmd.dpmodel.utils.neighbor_graph import (
209+
build_neighbor_graph,
210+
build_neighbor_graph_ase,
211+
)
212+
213+
if method == "dense":
214+
return build_neighbor_graph(coord, atype, box, rcut, pair_excl=pair_excl)
215+
if method == "ase":
216+
return build_neighbor_graph_ase(coord, atype, box, rcut, pair_excl=pair_excl)
217+
if method == "vesin":
218+
from deepmd.pt_expt.utils.vesin_graph_builder import (
219+
build_neighbor_graph_vesin,
220+
)
221+
222+
return build_neighbor_graph_vesin(coord, atype, box, rcut, pair_excl=pair_excl)
223+
if method == "nv":
224+
from deepmd.pt_expt.utils.nv_graph_builder import (
225+
build_neighbor_graph_nv,
226+
)
227+
228+
return build_neighbor_graph_nv(coord, atype, box, rcut, pair_excl=pair_excl)
229+
raise ValueError(
230+
f"unknown neighbor_graph_method {method!r}; use 'dense', 'ase', "
231+
"'vesin', or 'nv'"
232+
)
233+
234+
235+
class _WrapperForwardEnergyGraph:
236+
"""Graph twin of :class:`_WrapperForwardEnergy` for the Hessian.
237+
238+
Given flattened LOCAL coordinates for one frame, rebuilds the carry-all
239+
``NeighborGraph`` (so ``edge_vec`` tracks the coordinates through
240+
``autograd.functional.hessian``'s double-backward) and returns the scalar
241+
reduced-output component. Unlike the dense wrapper this differentiates
242+
w.r.t. the ``(nloc, 3)`` LOCAL coordinates directly -- the carry-all graph
243+
has no ghost nodes (PBC images enter only as edges, rebuilt from the same
244+
coords each call), so there is no extended-region ``nall -> nloc`` fold.
245+
"""
246+
247+
def __init__(
248+
self,
249+
model: Any,
250+
kk: str,
251+
ci: int,
252+
nloc: int,
253+
atype: torch.Tensor, # (1, nloc)
254+
box: torch.Tensor | None, # (1, ...) or None
255+
method: str,
256+
pair_excl: Any,
257+
rcut: float,
258+
fparam: torch.Tensor | None, # (1, ndf) or None
259+
aparam: torch.Tensor | None, # (1, nloc, nda) or None
260+
) -> None:
261+
self.model = model
262+
self.kk = kk
263+
self.ci = ci
264+
self.nloc = nloc
265+
self.atype = atype
266+
self.box = box
267+
self.method = method
268+
self.pair_excl = pair_excl
269+
self.rcut = rcut
270+
self.fparam = fparam
271+
self.aparam = aparam
272+
273+
def __call__(self, coord_flat: torch.Tensor) -> torch.Tensor:
274+
cc = coord_flat.reshape(1, self.nloc, 3)
275+
ng = _build_graph_for_method(
276+
self.method, cc, self.atype, self.box, self.rcut, self.pair_excl
277+
)
278+
atomic_ret = self.model.atomic_model.forward_common_atomic_graph(
279+
ng,
280+
self.atype.reshape(-1),
281+
fparam=self.fparam,
282+
aparam=self.aparam,
283+
)
284+
# atomic_ret[kk]: flat (N, *def), N == nloc for a single-frame carry-all
285+
# graph (all nodes owned); reduced output = sum over the node axis.
286+
atom_out = atomic_ret[self.kk]
287+
return atom_out.sum(dim=0).reshape(-1)[self.ci]
288+
289+
290+
def _cal_hessian_ext_graph(
291+
model: Any,
292+
kk: str,
293+
vdef: OutputVariableDef,
294+
coord: torch.Tensor,
295+
atype: torch.Tensor,
296+
box: torch.Tensor | None,
297+
fparam: torch.Tensor | None,
298+
aparam: torch.Tensor | None,
299+
method: str,
300+
pair_excl: Any,
301+
rcut: float,
302+
create_graph: bool = False,
303+
) -> torch.Tensor:
304+
"""Graph twin of :func:`_cal_hessian_ext`.
305+
306+
Computes the Hessian of the reduced output w.r.t. the LOCAL coordinates on
307+
the carry-all graph route. Returns shape ``[nf, *vdef.shape, nloc*3,
308+
nloc*3]`` -- the local-only counterpart of the dense extended Hessian,
309+
already in the same final layout the dense route reaches after
310+
``communicate_extended_output`` folds ``nall -> nloc`` (the graph route
311+
reduces over owned nodes, so no fold is needed). Node axis is
312+
atom-major/xyz-minor, matching the dense final reshape.
313+
"""
314+
nf, nloc, _ = coord.shape
315+
vsize = math.prod(vdef.shape)
316+
coord_flat = coord.reshape(nf, nloc * 3)
317+
hessians = []
318+
for ii in range(nf):
319+
for ci in range(vsize):
320+
wrapper = _WrapperForwardEnergyGraph(
321+
model,
322+
kk,
323+
ci,
324+
nloc,
325+
atype[ii : ii + 1],
326+
box[ii : ii + 1] if box is not None else None,
327+
method,
328+
pair_excl,
329+
rcut,
330+
fparam[ii : ii + 1] if fparam is not None else None,
331+
aparam[ii : ii + 1] if aparam is not None else None,
332+
)
333+
hess = torch.autograd.functional.hessian(
334+
wrapper,
335+
coord_flat[ii],
336+
create_graph=create_graph,
337+
) # (nloc*3, nloc*3)
338+
hessians.append(hess)
339+
return torch.stack(hessians).reshape(nf, *vdef.shape, nloc * 3, nloc * 3)
340+
341+
194342
def make_model(
195343
T_AtomicModel: type[BaseAtomicModel],
196344
T_Bases: tuple[type, ...] = (),
@@ -476,11 +624,6 @@ def _call_common_graph(
476624
``energy_redu``, ``energy_derv_r``, ``energy_derv_c_redu``, and
477625
``energy_derv_c`` when ``do_atomic_virial``).
478626
"""
479-
from deepmd.dpmodel.utils.neighbor_graph import (
480-
build_neighbor_graph,
481-
build_neighbor_graph_ase,
482-
)
483-
484627
# mirror the dpmodel guard: _resolve_graph_method's eligibility
485628
# check only protects the default (None) path; an EXPLICIT
486629
# neighbor_graph_method would otherwise reach the builders for
@@ -498,29 +641,7 @@ def _call_common_graph(
498641
# exported lower (forward_common_atomic_graph, which no longer
499642
# re-applies it) consumes a pre-excluded edge_mask.
500643
pair_excl = getattr(self.atomic_model, "pair_excl", None)
501-
if method == "dense":
502-
ng = build_neighbor_graph(cc, atype, bb, rcut, pair_excl=pair_excl)
503-
elif method == "ase":
504-
ng = build_neighbor_graph_ase(cc, atype, bb, rcut, pair_excl=pair_excl)
505-
elif method == "vesin":
506-
from deepmd.pt_expt.utils.vesin_graph_builder import (
507-
build_neighbor_graph_vesin,
508-
)
509-
510-
ng = build_neighbor_graph_vesin(
511-
cc, atype, bb, rcut, pair_excl=pair_excl
512-
)
513-
elif method == "nv":
514-
from deepmd.pt_expt.utils.nv_graph_builder import (
515-
build_neighbor_graph_nv,
516-
)
517-
518-
ng = build_neighbor_graph_nv(cc, atype, bb, rcut, pair_excl=pair_excl)
519-
else:
520-
raise ValueError(
521-
f"unknown neighbor_graph_method {method!r}; "
522-
"use 'dense', 'ase', 'vesin', or 'nv'"
523-
)
644+
ng = _build_graph_for_method(method, cc, atype, bb, rcut, pair_excl)
524645
nf, nloc = atype.shape[:2]
525646
atype_flat = atype.reshape(nf * nloc)
526647
model_predict = self.forward_common_lower_graph(
@@ -548,6 +669,30 @@ def _call_common_graph(
548669
and v.shape[:1] == torch.Size([N])
549670
):
550671
model_predict[k] = v.reshape(nf, nloc, *v.shape[1:])
672+
# Graph-native Hessian (parallel to the dense ``forward_common_atomic``
673+
# loop): differentiate the reduced output w.r.t. the LOCAL coords by
674+
# rebuilding the graph inside the wrapper. Added AFTER the unravel so
675+
# its ``(nf, *def, nloc, 3, nloc, 3)`` shape is returned as-is.
676+
# Eager-only, like the dense Hessian (autograd.functional.hessian
677+
# does not export/compile).
678+
aod = self.atomic_output_def()
679+
for kk in aod.keys():
680+
vdef = aod[kk]
681+
if vdef.reducible and vdef.r_hessian:
682+
model_predict[get_hessian_name(kk)] = _cal_hessian_ext_graph(
683+
self,
684+
kk,
685+
vdef,
686+
cc,
687+
atype,
688+
bb,
689+
fp,
690+
ap,
691+
method,
692+
pair_excl,
693+
rcut,
694+
create_graph=self.training,
695+
)
551696
return model_predict
552697

553698
def forward_common_atomic(

source/tests/pt_expt/model/test_dpa2_graph_lower.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ def _make_model(
176176
repformer_nsel: int = 150,
177177
repinit_nsel: int = 200,
178178
repformer_attn: bool = False,
179+
numb_fparam: int = 0,
179180
) -> EnergyModel:
180181
ds = _make_dpa2_descriptor(
181182
ntypes=self.nt,
@@ -189,6 +190,7 @@ def _make_model(
189190
ds.get_dim_out(),
190191
1,
191192
mixed_types=ds.mixed_types(),
193+
numb_fparam=numb_fparam,
192194
precision="float64",
193195
seed=GLOBAL_SEED,
194196
).to(self.device)
@@ -224,6 +226,61 @@ def test_force_virial_parity_vs_legacy(self) -> None:
224226
graph["energy_derv_c_redu"], legacy["energy_derv_c_redu"], **tol
225227
)
226228

229+
def test_graph_native_hessian_matches_dense(self) -> None:
230+
"""The graph route computes the Hessian natively and it matches the
231+
dense route bit-tight.
232+
233+
A Hessian-enabled graph-eligible DPA2 model default-flips to the graph
234+
route; ``_call_common_graph`` now runs its own ``_cal_hessian_ext_graph``
235+
loop (differentiating the reduced energy w.r.t. the LOCAL coords by
236+
rebuilding the carry-all graph inside the autograd wrapper), so
237+
``energy_derv_r_derv_r`` is produced without falling back to dense.
238+
The result must equal the dense route (forced via the
239+
``disable_graph_lower`` escape hatch on the same weights) at fp64
240+
parity, in the same ``(nf, 1, nloc*3, nloc*3)`` layout.
241+
242+
A non-binding repformer sel is used so graph and dense agree on the
243+
first derivatives too (attention off -- the fixture default); the
244+
Hessian parity would otherwise inherit the binding-sel divergence.
245+
"""
246+
from deepmd.pt_expt.train.training import (
247+
_model_uses_graph_lower,
248+
)
249+
250+
model = self._make_model() # non-binding sel, attention off
251+
model.eval()
252+
assert model.atomic_model.descriptor.uses_graph_lower() is True
253+
model.enable_hessian()
254+
# a Hessian model stays graph-routed: the graph now produces the Hessian.
255+
assert _model_uses_graph_lower(model) is True
256+
257+
box = self.cell.reshape(1, 9)
258+
# ``EnergyModel.forward`` exposes the Hessian under the ``"hessian"``
259+
# key (translated from ``energy_derv_r_derv_r``); it would KeyError if
260+
# the graph route failed to produce it.
261+
graph_out = model.forward(
262+
self.coord.clone().requires_grad_(True), self.atype, box=box
263+
)
264+
assert "hessian" in graph_out
265+
assert graph_out["hessian"].shape == (1, self.natoms * 3, self.natoms * 3)
266+
267+
# dense reference on the SAME weights via the escape hatch.
268+
ref_model = self._make_model()
269+
ref_model.eval()
270+
ref_model.load_state_dict(model.state_dict(), strict=False)
271+
ref_model.atomic_model.descriptor.disable_graph_lower()
272+
ref_model.enable_hessian()
273+
assert _model_uses_graph_lower(ref_model) is False
274+
dense_out = ref_model.forward(
275+
self.coord.clone().requires_grad_(True), self.atype, box=box
276+
)
277+
torch.testing.assert_close(
278+
graph_out["hessian"], dense_out["hessian"], rtol=1e-9, atol=1e-9
279+
)
280+
# Hessian symmetry (a genuine second derivative, not a shape artifact).
281+
h = graph_out["hessian"][0]
282+
torch.testing.assert_close(h, h.transpose(-1, -2), rtol=1e-9, atol=1e-9)
283+
227284
def test_disable_graph_lower_escape_hatch(self) -> None:
228285
"""``descriptor.disable_graph_lower()`` is the documented legacy-dense
229286
escape hatch: it flips ``uses_graph_lower()`` to ``False`` so the
@@ -495,6 +552,63 @@ def test_compiled_training_graph_smoke(self) -> None:
495552
**tol,
496553
)
497554

555+
def test_graph_lower_fparam_symbolic_trace_and_compile(self) -> None:
556+
"""A graph-eligible DPA2 model with ``numb_fparam > 0`` must export
557+
(``make_fx`` symbolic) AND inductor-compile.
558+
559+
Regression for the ``frame_id_from_n_node(graph.n_node)`` call in
560+
``dp_atomic_model.forward_atomic_graph``: without a STATIC ``n_total``
561+
it fell back to ``int(sum(n_node))``, which make_fx / torch.export
562+
cannot evaluate on a traced tensor (``GuardOnDataDependentSymNode``),
563+
so both the graph ``.pt2`` export and compiled training failed for any
564+
fparam model. Both must now trace/compile and match eager bit-tight.
565+
"""
566+
from deepmd.pt_expt.train.training import (
567+
_trace_and_compile_graph,
568+
)
569+
from deepmd.pt_expt.utils.serialization import (
570+
build_synthetic_graph_inputs,
571+
)
572+
573+
model = self._make_model(numb_fparam=2).to("cpu")
574+
model.eval()
575+
assert model.atomic_model.descriptor.uses_graph_lower() is True
576+
577+
sample = build_synthetic_graph_inputs(
578+
model,
579+
e_max=175,
580+
nframes=2,
581+
nloc=7,
582+
dtype=torch.float64,
583+
device=torch.device("cpu"),
584+
)
585+
atype, n_node, ei, ev, em, fp, ap, cs = sample
586+
assert fp is not None and fp.shape[-1] == 2, "fparam must be present"
587+
588+
# (a) symbolic-trace export path
589+
traced = model.forward_lower_graph_exportable(
590+
atype, n_node, ei, ev, em,
591+
fparam=fp, aparam=ap, do_atomic_virial=True,
592+
charge_spin=cs, tracing_mode="symbolic", _allow_non_fake_inputs=True,
593+
)
594+
out = traced(atype, n_node, ei, ev, em, fp, ap, cs)
595+
ref = model.forward_common_lower_graph(
596+
atype, n_node, ei, ev, em, fparam=fp, aparam=ap, do_atomic_virial=True
597+
)
598+
tol = {"rtol": 1e-12, "atol": 1e-12}
599+
torch.testing.assert_close(out["energy"], ref["energy_redu"], **tol)
600+
torch.testing.assert_close(
601+
out["force"], ref["energy_derv_r"].reshape(out["force"].shape), **tol
602+
)
603+
604+
# (b) compiled-training path (fparam threaded through the compile)
605+
compiled_lower, _ = _trace_and_compile_graph(model, fp, None, None)
606+
compiled_out = compiled_lower(atype, n_node, ei, ev, em, fp, ap, cs)
607+
ctol = {"rtol": 1e-10, "atol": 1e-10}
608+
torch.testing.assert_close(
609+
compiled_out["energy"], ref["energy_redu"], **ctol
610+
)
611+
498612
# ------------------------------------------------------------------
499613
# 2. the one piece of real code: the border_op graph exchange override.
500614
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)