@@ -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
0 commit comments