Skip to content

Commit cfb0cb8

Browse files
author
Han Wang
committed
feat(dpmodel): graph-native se_atten transformer attention (attn_layer > 0)
DescrptBlockSeAtten.call_graph grows _graph_attention: the dense per-center (nnei, nnei) attention square becomes the edge-pair axis (center_edge_pairs, ordered + self-included), softmax over keys becomes segment_softmax grouped by the query edge. Op-for-op mirror of GatedAttentionLayer.call (head_dim QKV slicing, normalize q/k/v, temperature/scaling, smooth shift trick, post-softmax sw and dotr weighting, residual + LayerNorm per layer). - shape-static adapter path (static_nnei threaded from the dense call adapter): bit-exact vs the dense body, rtol 1e-12, full flag matrix (attn_layer 1/2 x dotr x smooth x normalize x temperature, binding and non-binding sel). - carry-all (compact) graphs: exact for non-smooth; for smooth the dense branch keeps sel-padding slots in the softmax denominator (dense output is sel-DEPENDENT, up to ~1e-4) — the carry-all form drops those phantom terms by design (user decision 2026-07-03), pinned by a clean-divergence test. - edge_env_mat(return_sw=True) exposes the per-edge switch (zeroed on padding) for the smooth branch. - uses_graph_lower: attention configs are now graph-eligible (concat tebd, no exclude_types still required).
1 parent f4b7141 commit cfb0cb8

4 files changed

Lines changed: 392 additions & 26 deletions

File tree

deepmd/dpmodel/descriptor/dpa1.py

Lines changed: 166 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -428,15 +428,14 @@ def get_numb_attn_layer(self) -> int:
428428
def uses_graph_lower(self) -> bool:
429429
"""Returns whether this descriptor supports the graph-native lower.
430430
431-
The graph-native energy lower (``call_graph``) currently covers only the
432-
non-attention (``attn_layer == 0``) factorizable path with concat
433-
type-embedding and no type exclusion. Any other config (attention,
434-
``tebd_input_mode == "strip"``, ``exclude_types``) falls back to the
435-
legacy dense path, so those models keep working unchanged.
431+
The graph-native lower (``call_graph``) covers the factorizable path
432+
AND transformer attention (``attn_layer >= 0``, NeighborGraph PR-D)
433+
with concat type-embedding and no type exclusion. Remaining ineligible
434+
configs (``tebd_input_mode == "strip"``, ``exclude_types``) fall back
435+
to the legacy dense path, so those models keep working unchanged.
436436
"""
437437
return (
438-
self.se_atten.attn_layer == 0
439-
and self.se_atten.tebd_input_mode == "concat"
438+
self.se_atten.tebd_input_mode == "concat"
440439
and not self.se_atten.exclude_types
441440
)
442441

@@ -643,6 +642,9 @@ def _call_graph_adapter(
643642
graph,
644643
atype_local,
645644
type_embedding=self.type_embedding.call(),
645+
# the adapter graph is shape-static center-major (compact=False):
646+
# keep the attention pair enumeration nonzero-free (traceable)
647+
static_nnei=nnei,
646648
)
647649
# call_graph returns flat (N, ...) node axis; reshape to (nf, nloc, ...)
648650
# for the dense 5-tuple ABI -- this reshape is LOCAL to the adapter shim.
@@ -727,8 +729,9 @@ def call_graph(
727729
graph: Any,
728730
atype: Array,
729731
type_embedding: Array | None = None,
732+
static_nnei: int | None = None,
730733
) -> tuple[Array, Array]:
731-
"""Descriptor-level graph-native forward (``attn_layer == 0``).
734+
"""Descriptor-level graph-native forward.
732735
733736
Wraps the block kernel
734737
:meth:`DescrptBlockSeAtten.call_graph`, adds the descriptor-level
@@ -760,7 +763,7 @@ def call_graph(
760763
xp = array_api_compat.array_namespace(graph.edge_vec)
761764
dev = array_api_compat.device(graph.edge_vec)
762765
grrg, rot_mat = self.se_atten.call_graph(
763-
graph, atype, type_embedding=type_embedding
766+
graph, atype, type_embedding=type_embedding, static_nnei=static_nnei
764767
)
765768
# FLAT node axis (N, ...): no (nf, nloc) reshape -- ragged-native, spec.
766769
if self.concat_output_tebd:
@@ -1670,12 +1673,15 @@ def call_graph(
16701673
graph: Any,
16711674
atype: Array,
16721675
type_embedding: Array | None = None,
1676+
static_nnei: int | None = None,
16731677
) -> tuple[Array, Array]:
1674-
"""Graph-native forward (``attn_layer=0`` only).
1678+
"""Graph-native forward.
16751679
16761680
Bit-exact analogue of :meth:`call` on the SAME neighbor list, with the
16771681
neighbor-axis reduction replaced by a ``segment_sum`` over edge centers
1678-
(``dst``). Geometry enters only through ``graph.edge_vec``.
1682+
(``dst``) and the dense ``(nnei, nnei)`` transformer attention replaced
1683+
by pairs of edges sharing a center (``center_edge_pairs`` +
1684+
``segment_softmax``). Geometry enters only through ``graph.edge_vec``.
16791685
16801686
Parameters
16811687
----------
@@ -1687,6 +1693,12 @@ def call_graph(
16871693
(N,) flat node atom types (``N = sum(graph.n_node)``).
16881694
type_embedding
16891695
(ntypes_with_padding, tebd_dim) type-embedding table.
1696+
static_nnei
1697+
When the graph uses the shape-static center-major layout
1698+
(``from_dense_quartet(compact=False)``, ``E = n_center * nnei``),
1699+
pass ``nnei`` so the attention edge-pair enumeration stays
1700+
jit/export-traceable (no ``nonzero``). ``None`` (carry-all /
1701+
compact graphs) selects the dynamic eager form.
16901702
16911703
Returns
16921704
-------
@@ -1699,8 +1711,7 @@ def call_graph(
16991711
17001712
Notes
17011713
-----
1702-
Known limitations (NeighborGraph PR-A):
1703-
- ``attn_layer == 0`` only (attention lands in PR-D);
1714+
Known limitations:
17041715
- ``tebd_input_mode == "concat"`` only (strip mode lands later);
17051716
- ``exclude_types`` is not yet supported and raises (lands in a later PR).
17061717
"""
@@ -1709,11 +1720,6 @@ def call_graph(
17091720
segment_sum,
17101721
)
17111722

1712-
if self.attn_layer != 0:
1713-
raise NotImplementedError(
1714-
"graph path supports attn_layer=0 only (NeighborGraph PR-A); "
1715-
"attn_layer>0 lands in PR-D"
1716-
)
17171723
if self.tebd_input_mode not in ["concat"]:
17181724
raise NotImplementedError(
17191725
"graph path supports tebd_input_mode='concat' only (NeighborGraph PR-A)"
@@ -1738,7 +1744,7 @@ def call_graph(
17381744
# per-edge env-mat 4-vector, normalized by the center (dst) atom type.
17391745
# self.mean/self.stddev are slot-independent (ntypes, nnei, 4); slot 0 is
17401746
# the canonical per-type vector.
1741-
rr = edge_env_mat(
1747+
rr, sw_e = edge_env_mat(
17421748
graph.edge_vec,
17431749
center_type,
17441750
self.mean[:, 0, :],
@@ -1747,7 +1753,8 @@ def call_graph(
17471753
self.rcut_smth,
17481754
protection=self.env_protection,
17491755
edge_mask=graph.edge_mask,
1750-
) # (E, 4)
1756+
return_sw=True,
1757+
) # (E, 4), (E, 1) sw zeroed on padding
17511758
# radial channel
17521759
ss = rr[:, 0:1] # (E, 1)
17531760
# neighbor / center type embeddings (concat mode); ghost type == owner type
@@ -1764,6 +1771,13 @@ def call_graph(
17641771
ss = xp.concat([ss, atype_embd_nlist], axis=-1)
17651772
# embedding net (same weights as the dense path); applies on the last axis
17661773
gg = self.embeddings[0].call(ss) # (E, ng)
1774+
# transformer attention over each center's edges — mirrors the dense
1775+
# self.dpa1_attention(gg, nlist_mask, input_r, sw), which also runs on
1776+
# the UNMASKED gg (padding rows are neutralized afterwards).
1777+
if self.attn_layer > 0:
1778+
gg = self._graph_attention(
1779+
gg, rr, dst, n_total, graph.edge_mask, sw_e, static_nnei
1780+
)
17671781
# zero padding/guard edges BEFORE the segment sum
17681782
gg = gg * xp.astype(graph.edge_mask[:, None], gg.dtype)
17691783
# outer product (replaces the dense gg[:,:,:,None] * rr[:,:,None,:])
@@ -1784,6 +1798,138 @@ def call_graph(
17841798
rot_mat = gr[:, :, 1:]
17851799
return grrg, rot_mat
17861800

1801+
def _graph_attention(
1802+
self,
1803+
gg: Array,
1804+
rr: Array,
1805+
dst: Array,
1806+
n_total: int,
1807+
edge_mask: Array,
1808+
sw_e: Array,
1809+
static_nnei: int | None,
1810+
) -> Array:
1811+
"""Graph-native transformer attention over each center's edges.
1812+
1813+
Ragged reproduction of :class:`NeighborGatedAttention` /
1814+
:class:`GatedAttentionLayer`: edges sharing a center attend to each
1815+
other. The dense ``(nnei, nnei)`` square per center becomes the
1816+
edge-pair axis from ``center_edge_pairs(ordered=True,
1817+
include_self=True)``; softmax over the key axis becomes
1818+
``segment_softmax`` grouped by the query edge.
1819+
1820+
Parameters
1821+
----------
1822+
gg : (E, ng) per-edge embedding (UNMASKED, as in the dense path).
1823+
rr : (E, 4) per-edge env-mat vector (``rr[:, 1:4]`` carries direction).
1824+
dst : (E,) center of each edge.
1825+
n_total : number of centers.
1826+
edge_mask : (E,) real-vs-padding edge mask.
1827+
sw_e : (E, 1) smooth switch, zeroed on padding edges.
1828+
static_nnei : shape-static layout ``nnei`` or ``None`` (compact eager).
1829+
"""
1830+
from deepmd.dpmodel.utils.neighbor_graph import (
1831+
center_edge_pairs,
1832+
)
1833+
1834+
xp = array_api_compat.array_namespace(gg)
1835+
# per-edge normalized direction (mirrors the dense input_r,
1836+
# rr[..., 1:4] / max(|rr[..., 1:4]|, 1e-12))
1837+
dir3 = rr[:, 1:4]
1838+
normed = safe_for_vector_norm(dir3, axis=-1, keepdims=True)
1839+
input_r = dir3 / xp.maximum(normed, xp.full_like(normed, 1e-12)) # (E, 3)
1840+
# transformer neighbor-pairs: full ordered square incl. the diagonal
1841+
# (q_m . k_n is not symmetric and self-attention keeps m == n)
1842+
q_e, k_e, pair_mask = center_edge_pairs(
1843+
dst,
1844+
edge_mask,
1845+
n_total,
1846+
include_self=True,
1847+
ordered=True,
1848+
static_nnei=static_nnei,
1849+
)
1850+
for layer in self.dpa1_attention.attention_layers:
1851+
gg = self._graph_attention_one_layer(
1852+
layer, gg, input_r, sw_e, q_e, k_e, pair_mask
1853+
)
1854+
return gg
1855+
1856+
def _graph_attention_one_layer(
1857+
self,
1858+
layer: "NeighborGatedAttentionLayer",
1859+
gg: Array,
1860+
input_r: Array,
1861+
sw_e: Array,
1862+
q_e: Array,
1863+
k_e: Array,
1864+
pair_mask: Array,
1865+
) -> Array:
1866+
"""One residual attention layer, op-for-op vs the dense reference.
1867+
1868+
Mirrors ``NeighborGatedAttentionLayer.call`` (residual +
1869+
``GatedAttentionLayer.call`` + LayerNorm). Structural translation:
1870+
per-center ``q @ k^T`` -> per-pair ``q_m . k_n``; softmax over the key
1871+
axis -> ``segment_softmax`` grouped by the query edge. The smooth
1872+
branch keeps padding pairs IN the softmax denominator with ``sw = 0``
1873+
(weight ``exp(-attnw_shift)``), exactly like the dense branch, which
1874+
replaces the ``-inf`` masking by the switch weighting.
1875+
"""
1876+
from deepmd.dpmodel.utils.neighbor_graph import (
1877+
segment_softmax,
1878+
segment_sum,
1879+
)
1880+
1881+
xp = array_api_compat.array_namespace(gg)
1882+
e_tot = gg.shape[0]
1883+
gal = layer.attention_layer # GatedAttentionLayer
1884+
if gal.num_heads != 1:
1885+
raise NotImplementedError(
1886+
"graph attention assumes num_heads == 1 (dpa1 never exposes "
1887+
"num_heads; the dense head_dim QKV slicing relies on it)"
1888+
)
1889+
hd = gal.head_dim # == hidden_dim for num_heads == 1
1890+
residual = gg
1891+
# in_proj -> Q, K, V; mirror the dense HEAD_DIM slicing exactly
1892+
qkv = gal.in_proj.call(gg) # (E, 3 * hidden)
1893+
q = qkv[:, 0:hd]
1894+
k = qkv[:, hd : hd * 2]
1895+
v = qkv[:, hd * 2 : hd * 3]
1896+
if gal.normalize:
1897+
q = np_normalize(q, axis=-1)
1898+
k = np_normalize(k, axis=-1)
1899+
v = np_normalize(v, axis=-1)
1900+
q = q * gal.scaling
1901+
# per-pair logits q_m . k_n (num_heads == 1)
1902+
logits = xp.sum(
1903+
xp.take(q, q_e, axis=0) * xp.take(k, k_e, axis=0), axis=-1
1904+
) # (P,)
1905+
if gal.smooth:
1906+
# (logits + shift) * sw_m * sw_n - shift, then softmax WITHOUT the
1907+
# pair mask: padding pairs stay in the denominator at exp(-shift),
1908+
# mirroring the dense smooth branch (sw already zeroed on padding).
1909+
attnw_shift = 20.0 # dense GatedAttentionLayer.call default
1910+
sw_flat = sw_e[:, 0] # (E,)
1911+
sw_q = xp.take(sw_flat, q_e, axis=0)
1912+
sw_k = xp.take(sw_flat, k_e, axis=0)
1913+
logits = (logits + attnw_shift) * sw_q * sw_k - attnw_shift
1914+
w = segment_softmax(logits, q_e, e_tot) # (P,)
1915+
w = w * sw_q * sw_k
1916+
else:
1917+
# non-smooth: dense masks padding keys to -inf pre-softmax ==
1918+
# excluding them from the softmax entirely
1919+
w = segment_softmax(logits, q_e, e_tot, mask=pair_mask)
1920+
if gal.dotr:
1921+
angular = xp.sum(
1922+
xp.take(input_r, q_e, axis=0) * xp.take(input_r, k_e, axis=0),
1923+
axis=-1,
1924+
) # (P,) = input_r_m . input_r_n
1925+
w = w * angular
1926+
# o_m = sum_n w[m, n] v[n] -> segment_sum over the query edge
1927+
wv = w[:, None] * xp.take(v, k_e, axis=0) # (P, hd)
1928+
o = segment_sum(wv, q_e, e_tot) # (E, hd)
1929+
out = gal.out_proj.call(o) # (E, ng)
1930+
x = residual + out
1931+
return layer.attn_layer_norm.call(x)
1932+
17871933
def has_message_passing(self) -> bool:
17881934
"""Returns whether the descriptor block has message passing."""
17891935
return False

deepmd/dpmodel/utils/neighbor_graph/env.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ def edge_env_mat(
4141
rcut_smth: float,
4242
protection: float = 0.0,
4343
edge_mask: Array | None = None,
44-
) -> Array:
44+
return_sw: bool = False,
45+
) -> Array | tuple[Array, Array]:
4546
"""Compute the per-edge environment-matrix 4-vector.
4647
4748
Mirrors the math in ``_make_env_mat`` / ``EnvMat.call`` (env_mat.py)
@@ -79,6 +80,9 @@ def edge_env_mat(
7980
(E, 4) normalized environment-matrix vectors.
8081
Padding edges (``edge_vec = 0``) produce nonzero values but are
8182
masked by ``NeighborGraph.edge_mask`` downstream.
83+
When ``return_sw`` is True, returns ``(em, sw)`` where ``sw`` is the
84+
(E, 1) smooth switch, zeroed on padding edges (mirrors the dense
85+
``_make_env_mat`` mask; consumed by the smooth attention branch).
8286
"""
8387
xp = array_api_compat.array_namespace(edge_vec)
8488
dev = array_api_compat.device(edge_vec)
@@ -114,4 +118,13 @@ def edge_env_mat(
114118
avg = xp.take(xp.asarray(davg, device=dev), center_type, axis=0) # (E, 4)
115119
std = xp.take(xp.asarray(dstd, device=dev), center_type, axis=0) # (E, 4)
116120

121+
if return_sw:
122+
# per-edge switch, zeroed on padding edges — mirrors the dense
123+
# ``_make_env_mat`` (``weight = weight * mask``); used by the smooth
124+
# attention branch.
125+
if edge_mask is not None:
126+
sw_out = sw * xp.astype(edge_mask[:, None], sw.dtype)
127+
else:
128+
sw_out = sw
129+
return (em - avg) / std, sw_out
117130
return (em - avg) / std

source/tests/common/dpmodel/test_dpa1_call_graph_block.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,8 @@ def test_block_graph_equals_dense_any_sel(self, sel, type_one_side) -> None:
9090
atol=1e-12,
9191
)
9292

93-
def test_attn_layer_gt0_raises(self) -> None:
94-
"""The graph block kernel fail-fasts for attn_layer > 0 (unsupported)."""
95-
dd = DescrptDPA1(rcut=4.0, rcut_smth=0.5, sel=[20], ntypes=2, attn_layer=2)
96-
with pytest.raises(NotImplementedError):
97-
dd.se_atten.call_graph(None, np.array([0], dtype=np.int64))
93+
# attn_layer > 0 is supported since NeighborGraph PR-D; parity is covered
94+
# by test_dpa1_graph_attention_parity.py (the fail-fast test was removed).
9895

9996
def test_exclude_types_raises(self) -> None:
10097
"""The graph block kernel fail-fasts for exclude_types (not yet applied)."""

0 commit comments

Comments
 (0)