From cde9690d2321156e5e436fb5a11b1f07e94334f5 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sat, 10 Oct 2026 20:51:50 +0800 Subject: [PATCH 01/20] perf(pt): banded Wigner-D storage for the fused inference consumers (DP_PACKED_D_INFER) --- .../pt/model/descriptor/sezm_nn/edge_cache.py | 41 ++++- .../pt/model/descriptor/sezm_nn/embedding.py | 21 ++- deepmd/pt/model/descriptor/sezm_nn/so2.py | 7 +- deepmd/pt/model/descriptor/sezm_nn/wignerd.py | 165 +++++++++++++----- .../kernels/triton/sezm/so2_value_path.py | 149 +++++++++++----- deepmd/pt_expt/kernels/utils.py | 20 +++ 6 files changed, 305 insertions(+), 98 deletions(-) diff --git a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py index 4a67e2d1f9..dcb09f4c82 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py +++ b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py @@ -25,6 +25,9 @@ rearrange, ) +from deepmd.pt_expt.kernels.utils import ( + use_packed_d_infer, +) from .utils import ( get_promoted_dtype, nvtx_range, @@ -93,6 +96,12 @@ class EdgeFeatureCache(NamedTuple): edge_quat Per-edge global-to-local quaternion actually used to build ``D_full`` and ``Dt_full`` with shape (E, 4). Includes the optional random local-Z roll. + D_packed + Structural Wigner band with shape ``(E, 3*D-2)`` (the ``m = 0/-1/+1`` + rows per degree block, flash packed convention). Present only when the + banded inference storage is enabled and supported; then ``D_full`` and + ``Dt_full`` are ``None`` and the fused reduced-layout consumers address + this band instead. None otherwise. D_to_m_cache Lazy cache for projected D matrices keyed by a normalized ``"lmax:mmax"`` identifier. @@ -120,6 +129,7 @@ class EdgeFeatureCache(NamedTuple): csr_cache: dict[str, tuple[torch.Tensor, torch.Tensor]] | None = None edge_quat: torch.Tensor | None = None node_gate: torch.Tensor | None = None + D_packed: torch.Tensor | None = None def cached_edge_csr( @@ -435,7 +445,7 @@ def build_edge_cache( # === Step 6. Edge quaternion -> Wigner-D blocks === with nvtx_range("wigner_d"): - D_full, Dt_full, edge_quat = _build_edge_wigner( + D_full, Dt_full, edge_quat, D_packed = _build_edge_wigner( edge_vec=edge_vec, edge_len=edge_len, eps=eps, @@ -457,6 +467,7 @@ def build_edge_cache( D_full=D_full, Dt_full=Dt_full, edge_quat=edge_quat, + D_packed=D_packed, deg_norm_floor=deg_norm_floor, ) @@ -584,7 +595,7 @@ def build_edge_cache_from_edges( # === Step 4. Edge quaternion -> Wigner-D blocks === with nvtx_range("wigner_d"): - D_full, Dt_full, edge_quat = _build_edge_wigner( + D_full, Dt_full, edge_quat, D_packed = _build_edge_wigner( edge_vec=edge_vec, edge_len=edge_len, eps=eps, @@ -634,6 +645,7 @@ def build_edge_cache_from_edges( D_full=D_full, Dt_full=Dt_full, edge_quat=edge_quat, + D_packed=D_packed, deg_norm_floor=deg_norm_floor, node_gate=node_gate, ) @@ -695,9 +707,23 @@ def _build_edge_wigner( # === Step 3. Convert quaternions to packed Wigner-D blocks === if not build_full: - return None, None, edge_quat + return None, None, edge_quat, None + # ``wigner_calc`` may arrive as the calculator instance (callable through + # ``nn.Module.__call__``) or as a bound forward; accept both. + calc = getattr(wigner_calc, "__self__", wigner_calc) + if ( + use_packed_d_infer() + and callable(getattr(calc, "forward_band", None)) + and calc.band_supported() + ): + # Banded inference storage: the fused reduced-layout consumers (the + # rotate-mix operator and the flash aggregation) read only the three + # structural rows per degree block, so the dense surfaces are never + # materialized. Reference paths that need the dense layout must not + # enable the flag. + return None, None, edge_quat, calc.forward_band(edge_quat) D_full, Dt_full = wigner_calc(edge_quat) - return D_full, Dt_full, edge_quat + return D_full, Dt_full, edge_quat, None def _finalize_edge_cache( @@ -714,6 +740,7 @@ def _finalize_edge_cache( edge_quat: torch.Tensor, deg_norm_floor: float, node_gate: torch.Tensor | None = None, + D_packed: torch.Tensor | None = None, ) -> EdgeFeatureCache: """ Assemble the shared `EdgeFeatureCache` layout. @@ -781,6 +808,7 @@ def _finalize_edge_cache( csr_cache={}, edge_quat=edge_quat, node_gate=node_gate, + D_packed=D_packed, ) @@ -938,15 +966,19 @@ def edge_cache_to_dtype( _D_full = cache.D_full _Dt_full = cache.Dt_full _edge_quat = cache.edge_quat + _D_packed = cache.D_packed D_full: torch.Tensor | None = None Dt_full: torch.Tensor | None = None edge_quat: torch.Tensor | None = None + D_packed: torch.Tensor | None = None if _D_full is not None: D_full = _D_full.to(dtype=dtype) if _Dt_full is not None: Dt_full = _Dt_full.to(dtype=dtype) if _edge_quat is not None: edge_quat = _edge_quat.to(dtype=dtype) + if _D_packed is not None: + D_packed = _D_packed.to(dtype=dtype) # CSR views contain only integer topology. Preserve them across the dtype # conversion so every accelerated consumer shares the per-step sort. @@ -966,4 +998,5 @@ def edge_cache_to_dtype( csr_cache=None if cache.csr_cache is None else dict(cache.csr_cache), edge_quat=edge_quat, node_gate=cache.node_gate, + D_packed=D_packed, ) diff --git a/deepmd/pt/model/descriptor/sezm_nn/embedding.py b/deepmd/pt/model/descriptor/sezm_nn/embedding.py index 296205002a..cc14ba70bb 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/embedding.py +++ b/deepmd/pt/model/descriptor/sezm_nn/embedding.py @@ -272,12 +272,21 @@ def forward( # Advanced indexing pairs one packed non-scalar row with the zonal m=0 column # from the same degree block in Dt_full. if zonal_coupling is None: - Dt_full = edge_cache.Dt_full # (E, D, D) - zonal_coupling = Dt_full[ - :, - self.non_scalar_row_index, - self.zonal_m0_col_index_for_row, - ] # (E, D-1) + if getattr(edge_cache, "D_packed", None) is not None: + # The m = 0 band segment holds D[r0(l), d] per column d; the + # zonal entry Dt[row(l, m), col(l, 0)] equals D[r0(l), row(l, m)], + # so indexing the segment by the non-scalar rows reproduces + # the gather below without the dense surface. + zonal_coupling = getattr(edge_cache, "D_packed", None)[ + :, self.non_scalar_row_index + ] + else: + Dt_full = edge_cache.Dt_full # (E, D, D) + zonal_coupling = Dt_full[ + :, + self.non_scalar_row_index, + self.zonal_m0_col_index_for_row, + ] # (E, D-1) # === Step 3. Broadcast radial features per row === # Each non-scalar packed row reuses the radial feature of its degree l. diff --git a/deepmd/pt/model/descriptor/sezm_nn/so2.py b/deepmd/pt/model/descriptor/sezm_nn/so2.py index 4803b5d7c5..453ccd4725 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/so2.py +++ b/deepmd/pt/model/descriptor/sezm_nn/so2.py @@ -2016,7 +2016,12 @@ def forward_attention_flash( dst = edge_cache.dst n_node = x.shape[0] order, row_ptr = cached_edge_csr(edge_cache, "dst", n_node) - rotation = edge_cache.Dt_full + # The banded inference storage serves both rotation directions: the + # flash packed convention stores the structural band of D, whose + # entries are exactly the Dt columns the aggregation reads. + rotation = getattr(edge_cache, "D_packed", None) + if rotation is None: + rotation = edge_cache.Dt_full if rotation is None: rotation = self._cuda_value_train.edge_runs(edge_cache) pre_gate = self._flash_atten_fn( diff --git a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py index b283e3a7e5..bd31b5cf39 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py +++ b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py @@ -524,84 +524,95 @@ def __init__( persistent=True, ) - def forward( + def _compute_all_blocks( self, edge_quaternion: torch.Tensor - ) -> tuple[torch.Tensor, torch.Tensor]: - """ - Build packed block-diagonal Wigner-D matrices from edge quaternions. + ) -> list[tuple[int, torch.Tensor]]: + """Compute every per-degree block below the polynomial tail. - Parameters - ---------- - edge_quaternion - Unit quaternions with shape ``(E, 4)`` representing the global->local - edge rotation. - - Returns - ------- - tuple[torch.Tensor, torch.Tensor] - ``(D_full, Dt_full)`` with shape ``(E, (lmax+1)^2, (lmax+1)^2)``. + Returns ``[(l, block)]`` with ``block`` of shape + ``(E, 2l+1, 2l+1)``, degrees ascending; degree 0 (the constant 1) + is not included. """ - edge_quaternion = quaternion_normalize( - edge_quaternion.to(dtype=self.dtype), - eps=self.eps, - ) - n_edge = edge_quaternion.shape[0] - D_full = torch.zeros( - n_edge, - self.dim_full, - self.dim_full, - dtype=edge_quaternion.dtype, - device=edge_quaternion.device, - ) - D_full[:, 0, 0] = 1.0 - + blocks: list[tuple[int, torch.Tensor]] = [] if self.lmax >= 1: with nvtx_range("WignerD/l1"): - D_full[:, 1:4, 1:4] = self._compute_l1_block(edge_quaternion) - + blocks.append((1, self._compute_l1_block(edge_quaternion))) if self.lmax >= 2: with nvtx_range("WignerD/l2"): - D_full[:, 4:9, 4:9] = self._compute_l2_block(edge_quaternion) - + blocks.append((2, self._compute_l2_block(edge_quaternion))) if self.lmax >= 3: if self.lmax >= 4: with nvtx_range("WignerD/l3l4"): D_l3, D_l4 = self._compute_l3l4_blocks(edge_quaternion) - D_full[:, 9:16, 9:16] = D_l3 - D_full[:, 16:25, 16:25] = D_l4 + blocks.append((3, D_l3)) + blocks.append((4, D_l4)) else: with nvtx_range("WignerD/l3"): - D_full[:, 9:16, 9:16] = self._compute_l3_block(edge_quaternion) - + blocks.append((3, self._compute_l3_block(edge_quaternion))) if self.lmax >= 5: if self.lmax >= 6: with nvtx_range("WignerD/l5l6"): D_l5, D_l6 = self._compute_l5l6_blocks(edge_quaternion) - D_full[:, 25:36, 25:36] = D_l5 - D_full[:, 36:49, 36:49] = D_l6 + blocks.append((5, D_l5)) + blocks.append((6, D_l6)) else: with nvtx_range("WignerD/l5"): - D_full[:, 25:36, 25:36] = self._compute_l5_block(edge_quaternion) - + blocks.append((5, self._compute_l5_block(edge_quaternion))) if self.lmax >= 7: if self.lmax >= 8: with nvtx_range("WignerD/l7l8"): D_l7, D_l8 = self._compute_l7l8_blocks(edge_quaternion) - D_full[:, 49:64, 49:64] = D_l7 - D_full[:, 64:81, 64:81] = D_l8 + blocks.append((7, D_l7)) + blocks.append((8, D_l8)) else: with nvtx_range("WignerD/l7"): - D_full[:, 49:64, 49:64] = self._compute_l7_block(edge_quaternion) - + blocks.append((7, self._compute_l7_block(edge_quaternion))) if self.lmax >= 9: if self.lmax >= 10: with nvtx_range("WignerD/l9l10"): D_l9, D_l10 = self._compute_l9l10_blocks(edge_quaternion) - D_full[:, 81:100, 81:100] = D_l9 - D_full[:, 100:121, 100:121] = D_l10 + blocks.append((9, D_l9)) + blocks.append((10, D_l10)) else: with nvtx_range("WignerD/l9"): - D_full[:, 81:100, 81:100] = self._compute_l9_block(edge_quaternion) + blocks.append((9, self._compute_l9_block(edge_quaternion))) + return blocks + + def forward( + self, edge_quaternion: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """ + Build packed block-diagonal Wigner-D matrices from edge quaternions. + + Parameters + ---------- + edge_quaternion + Unit quaternions with shape ``(E, 4)`` representing the global->local + edge rotation. + + Returns + ------- + tuple[torch.Tensor, torch.Tensor] + ``(D_full, Dt_full)`` with shape ``(E, (lmax+1)^2, (lmax+1)^2)``. + """ + edge_quaternion = quaternion_normalize( + edge_quaternion.to(dtype=self.dtype), + eps=self.eps, + ) + n_edge = edge_quaternion.shape[0] + D_full = torch.zeros( + n_edge, + self.dim_full, + self.dim_full, + dtype=edge_quaternion.dtype, + device=edge_quaternion.device, + ) + D_full[:, 0, 0] = 1.0 + + for l_degree, block in self._compute_all_blocks(edge_quaternion): + base = l_degree * l_degree + size = 2 * l_degree + 1 + D_full[:, base : base + size, base : base + size] = block if self.lmax >= self.poly_lmin: with nvtx_range("WignerD/polynomial"): @@ -641,6 +652,66 @@ def forward( Dt_full = Dt_full.contiguous() return D_full, Dt_full + def band_supported(self) -> bool: + """Return whether :meth:`forward_band` covers this configuration. + + The band layout stores three structural rows per degree block, which + only the per-degree ladder below the polynomial tail can produce. + """ + return self.lmax < self.poly_lmin + + def forward_band(self, edge_quaternion: torch.Tensor) -> torch.Tensor: + """ + Build the structural Wigner band in the flash packed convention. + + The returned tensor has shape ``(E, 3 * D - 2)`` with + ``D = (lmax + 1)^2`` and holds, per output column ``d`` of degree + ``l = degree(d)``, the three Wigner entries the reduced ``mmax = 1`` + consumers read: + + - slots ``[0, D)``: ``D[l^2 + l, d]`` (the ``m = 0`` row), + - slots ``[D, 2D - 2)``: ``D[l^2 + l - 1, d]`` (``m = -1`` row, + degrees ``l >= 1`` only, stored at slot ``D + d - 1``), + - slots ``[2D - 1, 3D - 2)``: ``D[l^2 + l + 1, d]`` (``m = +1`` row, + stored at slot ``2D + d - 2``). + + The stored values are the same floats the dense layout keeps at those + ``(row, column)`` pairs; only the storage order differs, so consumers + addressing the band produce bit-identical results. The band is a + projection for the reduced (``mmax = 1``) consumers, not the full + matrix: the remaining block rows are never read by them, and the + dense layout cannot be reconstructed from the band alone. + + Parameters + ---------- + edge_quaternion + Unit quaternions with shape ``(E, 4)``. + + Returns + ------- + torch.Tensor + Band tensor with shape ``(E, 3 * dim_full - 2)``. + """ + if not self.band_supported(): + raise RuntimeError( + f"banded Wigner storage requires lmax < {self.poly_lmin}, " + f"got lmax={self.lmax}" + ) + edge_quaternion = quaternion_normalize( + edge_quaternion.to(dtype=self.dtype), + eps=self.eps, + ) + n_edge = edge_quaternion.shape[0] + blocks = self._compute_all_blocks(edge_quaternion) + m0_rows = [edge_quaternion.new_ones((n_edge, 1))] + mm_rows: list[torch.Tensor] = [] + mp_rows: list[torch.Tensor] = [] + for l_degree, block in blocks: + m0_rows.append(block[:, l_degree, :]) + mm_rows.append(block[:, l_degree - 1, :]) + mp_rows.append(block[:, l_degree + 1, :]) + return torch.cat(m0_rows + mm_rows + mp_rows, dim=1).contiguous() + def forward_zonal( self, edge_quaternion: torch.Tensor, diff --git a/deepmd/pt_expt/kernels/triton/sezm/so2_value_path.py b/deepmd/pt_expt/kernels/triton/sezm/so2_value_path.py index 7a8ac3fd43..e98a627c93 100644 --- a/deepmd/pt_expt/kernels/triton/sezm/so2_value_path.py +++ b/deepmd/pt_expt/kernels/triton/sezm/so2_value_path.py @@ -516,6 +516,7 @@ def _rotate_mix_fwd_kernel( CW: tl.constexpr, # true C_wide; BC = next_power_of_2(CW) lanes with mask BC: tl.constexpr, RANK: tl.constexpr, + PACKED: tl.constexpr, ): """One program per edge, channels vectorized. @@ -538,7 +539,12 @@ def _rotate_mix_fwd_kernel( cmask = chan < CW src = tl.load(src_ptr + edge).to(tl.int64) x_base = x_ptr + src * x_sn - d_base = w_ptr + edge * DIM * DIM + # PACKED: wigner holds the (E, 3*D-2) structural band (flash packed + # convention); the band slots replace the dense (row, column) offsets. + if PACKED: + d_base = w_ptr + edge * (3 * DIM - 2) + else: + d_base = w_ptr + edge * DIM * DIM # === Phase 1. Rotate to the local frame (registers) === xrows = () @@ -555,12 +561,20 @@ def _rotate_mix_fwd_kernel( acc0 = tl.zeros((BC,), dtype=tl.float32) accm = tl.zeros((BC,), dtype=tl.float32) accp = tl.zeros((BC,), dtype=tl.float32) + if PACKED: + off0, offm, offp = base, DIM + base - 1, 2 * DIM + base - 2 + else: + off0, offm, offp = ( + r0 * DIM + base, + (r0 - 1) * DIM + base, + (r0 + 1) * DIM + base, + ) for j in tl.static_range(2 * l + 1): xv = xrows[l * l + j] - acc0 += tl.load(d_base + r0 * DIM + base + j) * xv + acc0 += tl.load(d_base + off0 + j) * xv if l >= 1: - accm += tl.load(d_base + (r0 - 1) * DIM + base + j) * xv - accp += tl.load(d_base + (r0 + 1) * DIM + base + j) * xv + accm += tl.load(d_base + offm + j) * xv + accp += tl.load(d_base + offp + j) * xv rows0 = rows0 + (acc0,) if l >= 1: rows_m = rows_m + (accm,) @@ -648,6 +662,7 @@ def _rotate_mix_bwd_kernel( CW: tl.constexpr, BC: tl.constexpr, RANK: tl.constexpr, + PACKED: tl.constexpr, ): """Backward of the fused front end (one program per edge). @@ -674,7 +689,10 @@ def _rotate_mix_bwd_kernel( tl.load(cb_ptr + r * CW + chan, mask=cmask, other=0.0).to(tl.float32), ) x_base = x_ptr + src * x_sn - d_base = w_ptr + edge * DIM * DIM + if PACKED: + d_base = w_ptr + edge * (3 * DIM - 2) + else: + d_base = w_ptr + edge * DIM * DIM if RANK == 0: kc_base = kc_ptr + edge * NS0 * CW gkc_base = gkc_ptr + edge * NS0 * CW @@ -698,12 +716,20 @@ def _rotate_mix_bwd_kernel( acc0 = tl.zeros((BC,), dtype=tl.float32) accm = tl.zeros((BC,), dtype=tl.float32) accp = tl.zeros((BC,), dtype=tl.float32) + if PACKED: + off0, offm, offp = base, DIM + base - 1, 2 * DIM + base - 2 + else: + off0, offm, offp = ( + r0 * DIM + base, + (r0 - 1) * DIM + base, + (r0 + 1) * DIM + base, + ) for j in tl.static_range(2 * l + 1): xv = xrows[l * l + j] - acc0 += tl.load(d_base + r0 * DIM + base + j) * xv + acc0 += tl.load(d_base + off0 + j) * xv if l >= 1: - accm += tl.load(d_base + (r0 - 1) * DIM + base + j) * xv - accp += tl.load(d_base + (r0 + 1) * DIM + base + j) * xv + accm += tl.load(d_base + offm + j) * xv + accp += tl.load(d_base + offp + j) * xv rows0 = rows0 + (acc0,) if l >= 1: rows_m = rows_m + (accm,) @@ -755,7 +781,10 @@ def _rotate_mix_bwd_kernel( ) # === Phase 2. Rotation backward with g_local formed on the fly === - gd_base = gw_ptr + edge * DIM * DIM + if PACKED: + gd_base = gw_ptr + edge * (3 * DIM - 2) + else: + gd_base = gw_ptr + edge * DIM * DIM for l in tl.static_range(L + 1): base = l * l r0 = base + l @@ -797,16 +826,24 @@ def _rotate_mix_bwd_kernel( gp += keff * gy[NS0 + L + o] for j in tl.static_range(2 * l + 1): col = base + j + if PACKED: + a0, am, ap = col, DIM + col - 1, 2 * DIM + col - 2 + else: + a0, am, ap = ( + r0 * DIM + col, + (r0 - 1) * DIM + col, + (r0 + 1) * DIM + col, + ) xv = xrows[l * l + j] - w0 = tl.load(d_base + r0 * DIM + col) + w0 = tl.load(d_base + a0) gx_row = w0 * g0 - tl.store(gd_base + r0 * DIM + col, tl.sum(g0 * xv)) + tl.store(gd_base + a0, tl.sum(g0 * xv)) if l >= 1: - wmv = tl.load(d_base + (r0 - 1) * DIM + col) - wpv = tl.load(d_base + (r0 + 1) * DIM + col) + wmv = tl.load(d_base + am) + wpv = tl.load(d_base + ap) gx_row += wmv * gm + wpv * gp - tl.store(gd_base + (r0 - 1) * DIM + col, tl.sum(gm * xv)) - tl.store(gd_base + (r0 + 1) * DIM + col, tl.sum(gp * xv)) + tl.store(gd_base + am, tl.sum(gm * xv)) + tl.store(gd_base + ap, tl.sum(gp * xv)) tl.store( gxe_ptr + edge * DIM * CW + col * CW + chan, gx_row, mask=cmask ) @@ -831,6 +868,7 @@ def _rotate_mix_bwd_block_kernel( CP: tl.constexpr, # next power of two >= CW (vector lane count) RANK: tl.constexpr, BLOCK_E: tl.constexpr, + PACKED: tl.constexpr, ): """Edge-block variant of the rotate+mix backward. @@ -874,8 +912,12 @@ def _rotate_mix_bwd_block_kernel( src = tl.load(src_ptr + eq, mask=e_mask, other=0).to(tl.int64) x_base = x_ptr + (src * x_sn)[:, None] - d_base = w_ptr + eq * DIM * DIM - gd_base = gw_ptr + eq * DIM * DIM + if PACKED: + d_base = w_ptr + eq * (3 * DIM - 2) + gd_base = gw_ptr + eq * (3 * DIM - 2) + else: + d_base = w_ptr + eq * DIM * DIM + gd_base = gw_ptr + eq * DIM * DIM gxe_base = gxe_ptr + (eq * DIM * CW)[:, None] if RANK == 0: kc_base = kc_ptr + (eq * NS0 * CW)[:, None] @@ -918,17 +960,21 @@ def _rotate_mix_bwd_block_kernel( xl0 = tl.zeros((BLOCK_E, CP), dtype=tl.float32) xlm = tl.zeros((BLOCK_E, CP), dtype=tl.float32) xlp = tl.zeros((BLOCK_E, CP), dtype=tl.float32) + if PACKED: + off0, offm, offp = base, DIM + base - 1, 2 * DIM + base - 2 + else: + off0, offm, offp = ( + r0 * DIM + base, + (r0 - 1) * DIM + base, + (r0 + 1) * DIM + base, + ) for j in tl.static_range(2 * l + 1): xv = xrows[j] - w0 = tl.load(d_base + r0 * DIM + base + j, mask=e_mask, other=0.0) + w0 = tl.load(d_base + off0 + j, mask=e_mask, other=0.0) xl0 += w0[:, None] * xv if l >= 1: - wm = tl.load( - d_base + (r0 - 1) * DIM + base + j, mask=e_mask, other=0.0 - ) - wp = tl.load( - d_base + (r0 + 1) * DIM + base + j, mask=e_mask, other=0.0 - ) + wm = tl.load(d_base + offm + j, mask=e_mask, other=0.0) + wp = tl.load(d_base + offp + j, mask=e_mask, other=0.0) xlm += wm[:, None] * xv xlp += wp[:, None] * xv @@ -981,24 +1027,24 @@ def _rotate_mix_bwd_block_kernel( # Rotation backward: node gradient rows and Wigner gradients. for j in tl.static_range(2 * l + 1): col = base + j + if PACKED: + a0, am, ap = col, DIM + col - 1, 2 * DIM + col - 2 + else: + a0, am, ap = ( + r0 * DIM + col, + (r0 - 1) * DIM + col, + (r0 + 1) * DIM + col, + ) xv = xrows[j] - w0 = tl.load(d_base + r0 * DIM + col, mask=e_mask, other=0.0) + w0 = tl.load(d_base + a0, mask=e_mask, other=0.0) gx_row = w0[:, None] * g0 - tl.store(gd_base + r0 * DIM + col, tl.sum(g0 * xv, axis=1), mask=e_mask) + tl.store(gd_base + a0, tl.sum(g0 * xv, axis=1), mask=e_mask) if l >= 1: - wm = tl.load(d_base + (r0 - 1) * DIM + col, mask=e_mask, other=0.0) - wp = tl.load(d_base + (r0 + 1) * DIM + col, mask=e_mask, other=0.0) + wm = tl.load(d_base + am, mask=e_mask, other=0.0) + wp = tl.load(d_base + ap, mask=e_mask, other=0.0) gx_row += wm[:, None] * gm + wp[:, None] * gp - tl.store( - gd_base + (r0 - 1) * DIM + col, - tl.sum(gm * xv, axis=1), - mask=e_mask, - ) - tl.store( - gd_base + (r0 + 1) * DIM + col, - tl.sum(gp * xv, axis=1), - mask=e_mask, - ) + tl.store(gd_base + am, tl.sum(gm * xv, axis=1), mask=e_mask) + tl.store(gd_base + ap, tl.sum(gp * xv, axis=1), mask=e_mask) tl.store(gxe_base + col * CW + chan[None, :], gx_row, mask=em) @triton.jit @@ -2481,7 +2527,13 @@ def _rotate_mix_impl( # The source CSR view rides through the forward untouched so the autograd # context can hand it to the backward's segment reduction. del src_order, src_rowptr + packed = wigner.dim() == 2 if not _use_triton(x): + if packed: + raise RuntimeError( + "the banded Wigner layout requires the Triton rotate-mix kernels; " + "unset DP_PACKED_D_INFER or enable the Triton inference path" + ) return _rotate_mix_reference(x, src, wigner, kc, cb, lmax, n_focus, rank) n_edge = src.shape[0] c_wide = int(x.shape[2]) @@ -2506,6 +2558,7 @@ def _rotate_mix_impl( CW=c_wide, BC=triton.next_power_of_2(c_wide), RANK=int(rank), + PACKED=packed, num_warps=warps, num_stages=stages, ) @@ -2523,7 +2576,13 @@ def _rotate_mix_bwd_impl( n_focus: int, rank: int, ) -> tuple[Tensor, Tensor, Tensor]: + packed = wigner.dim() == 2 if not _use_triton(x): + if packed: + raise RuntimeError( + "the banded Wigner layout requires the Triton rotate-mix kernels; " + "unset DP_PACKED_D_INFER or enable the Triton inference path" + ) return _rotate_mix_backward_reference( grad_u, x, src, wigner, kc, cb, lmax, n_focus, rank ) @@ -2563,6 +2622,7 @@ def _rotate_mix_bwd_impl( CP=triton.next_power_of_2(c_wide), RANK=int(rank), BLOCK_E=block_e, + PACKED=packed, num_warps=warps, num_stages=stages, ) @@ -2586,6 +2646,7 @@ def _rotate_mix_bwd_impl( CW=c_wide, BC=triton.next_power_of_2(c_wide), RANK=int(rank), + PACKED=packed, num_warps=warps, num_stages=stages, ) @@ -5032,6 +5093,14 @@ def _is_supported(conv: SO2Convolution) -> bool: _mixing_stack_op.register_autocast("cuda", torch.bfloat16) +def _packed_d(edge_cache) -> Tensor: + """Return the banded Wigner storage when present, else the dense surface.""" + band = getattr(edge_cache, "D_packed", None) + if band is not None: + return band + return edge_cache.D_full + + class _TritonRotateMix: """Per-convolution entry running rotate-to-local + degree mixing fused. @@ -5100,7 +5169,7 @@ def __call__( src, src_order, src_rowptr, - edge_cache.D_full, + _packed_d(edge_cache), kc.contiguous(), cb.contiguous(), conv.lmax, @@ -5332,7 +5401,7 @@ def __call__( src, src_order, src_rowptr, - edge_cache.D_full, + _packed_d(edge_cache), kc.contiguous(), cb.contiguous(), conv.lmax, diff --git a/deepmd/pt_expt/kernels/utils.py b/deepmd/pt_expt/kernels/utils.py index c6e54bd17f..ca0b796bad 100644 --- a/deepmd/pt_expt/kernels/utils.py +++ b/deepmd/pt_expt/kernels/utils.py @@ -380,3 +380,23 @@ def use_amp_infer() -> bool: ``True`` when ``DP_AMP_INFER`` is set to a truthy value. """ return os.environ.get("DP_AMP_INFER", "0").strip().lower() in _INFER_TRUE + + +def use_packed_d_infer() -> bool: + """Return whether inference stores the Wigner rotations in banded form. + + The flag is controlled by the ``DP_PACKED_D_INFER`` environment variable + and is read when the edge cache is built. When truthy (and the model + configuration supports it), the edge cache stores only the ``3 * D - 2`` + structural band entries of the block-diagonal Wigner matrices per edge + instead of the dense ``(E, D, D)`` surfaces; the fused Triton consumers + (rotate-mix, flash aggregation) address the band directly, and paths that + still need the dense layout unpack it on demand. The stored values are the + same floats, so consumer outputs are bit-identical to the dense mode. + + Returns + ------- + bool + ``True`` when ``DP_PACKED_D_INFER`` is set to a truthy value. + """ + return os.environ.get("DP_PACKED_D_INFER", "0").strip().lower() in _INFER_TRUE From 3c65fbde48272baef45f59fa344f843f05adc908 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sat, 10 Oct 2026 22:04:37 +0800 Subject: [PATCH 02/20] test(pt): bitwise coverage for the banded Wigner storage --- .../pt/model/test_descriptor_sezm_triton.py | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index f359f073d6..4191348f0a 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2053,3 +2053,160 @@ def test_tune_missing_configs_runs_the_real_flash_bwd_sweep(self): if __name__ == "__main__": unittest.main() + + +class TestSeZMTritonWignerBand(unittest.TestCase): + """Cross-check the banded Wigner storage against the dense layout. + + The band keeps the three structural rows per degree block in the flash + packed convention; every consumer addressing it must stay bit-identical + to the dense ``(E, D, D)`` layout it replaces. + """ + + N_EDGE = 2048 + N_NODE = 128 + + def _quaternion(self, n, seed): + generator = torch.Generator(device="cuda").manual_seed(seed) + quaternion = torch.randn(n, 4, device="cuda", generator=generator) + return quaternion / quaternion.norm(dim=-1, keepdim=True) + + def _band_segments(self, band, dim): + return band[:, :dim], band[:, dim : 2 * dim - 1], band[:, 2 * dim - 1 :] + + @_GPU_KERNELS + def test_band_matches_dense_blocks(self): + from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( + WignerDCalculator, + ) + + for lmax in (1, 2, 3, 4, 5): + with self.subTest(lmax=lmax): + calc = WignerDCalculator(lmax, dtype=torch.float32).to("cuda") + quaternion = self._quaternion(64, 17 + lmax) + dense, _ = calc(quaternion) + band = calc.forward_band(quaternion) + dim = (lmax + 1) ** 2 + self.assertEqual(tuple(band.shape), (64, 3 * dim - 2)) + m0, mm, mp = self._band_segments(band, dim) + self.assertTrue(torch.equal(m0[:, 0], dense[:, 0, 0])) + for degree in range(1, lmax + 1): + base, size = degree * degree, 2 * degree + 1 + r0 = base + degree + cols = slice(base, base + size) + self.assertTrue(torch.equal(m0[:, cols], dense[:, r0, cols])) + self.assertTrue( + torch.equal( + mm[:, base - 1 : base - 1 + size], + dense[:, r0 - 1, cols], + ) + ) + self.assertTrue( + torch.equal( + mp[:, base - 1 : base - 1 + size], + dense[:, r0 + 1, cols], + ) + ) + + @_GPU_KERNELS + def test_rotate_mix_packed_bitwise(self): + from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( + WignerDCalculator, + ) + from deepmd.pt_expt.kernels.triton.sezm import so2_value_path as svp + + lmax, n_focus, focus_dim = 3, 2, 32 + c_wide = n_focus * focus_dim + dim = (lmax + 1) ** 2 + calc = WignerDCalculator(lmax, dtype=torch.float32).to("cuda") + quaternion = self._quaternion(self.N_EDGE, 23) + dense = calc(quaternion)[0] + band = calc.forward_band(quaternion) + + generator = torch.Generator(device="cuda").manual_seed(29) + x = ( + torch.randn(self.N_NODE, dim, c_wide, device="cuda", generator=generator) + * 0.5 + ).contiguous() + src = torch.randint( + 0, self.N_NODE, (self.N_EDGE,), device="cuda", generator=generator + ) + kc = torch.randn(self.N_EDGE, 25, device="cuda", generator=generator) * 0.2 + cb = (torch.randn(1, c_wide, device="cuda", generator=generator) * 0.3).reshape( + -1 + ) + + u0 = svp._rotate_mix_impl(x, src, None, None, dense, kc, cb, lmax, n_focus, 1) + u1 = svp._rotate_mix_impl(x, src, None, None, band, kc, cb, lmax, n_focus, 1) + self.assertTrue(torch.equal(u0, u1)) + grad_u = torch.randn(u0.shape, device="cuda", generator=generator) + gx0, gw0, gkc0 = svp._rotate_mix_bwd_impl( + grad_u, x, src, dense, kc, cb, lmax, n_focus, 1 + ) + gx1, gw1, gkc1 = svp._rotate_mix_bwd_impl( + grad_u, x, src, band, kc, cb, lmax, n_focus, 1 + ) + self.assertTrue(torch.equal(gx0, gx1)) + self.assertTrue(torch.equal(gkc0, gkc1)) + # The dense Wigner gradient is zero off the structural band; compare + # the three band segments slot by slot. + m0 = [gw0[:, 0, 0:1]] + mm: list[torch.Tensor] = [] + mp: list[torch.Tensor] = [] + for degree in range(1, lmax + 1): + base, size = degree * degree, 2 * degree + 1 + r0 = base + degree + m0.append(gw0[:, r0, base : base + size]) + mm.append(gw0[:, r0 - 1, base : base + size]) + mp.append(gw0[:, r0 + 1, base : base + size]) + self.assertTrue(torch.equal(torch.cat(m0, dim=1), gw1[:, :dim])) + self.assertTrue(torch.equal(torch.cat(mm, dim=1), gw1[:, dim : 2 * dim - 1])) + self.assertTrue(torch.equal(torch.cat(mp, dim=1), gw1[:, 2 * dim - 1 :])) + + @_GPU_KERNELS + def test_value_path_packed_bitwise(self): + from deepmd.pt_expt.kernels.triton.sezm.so2_value_path import ( + make_triton_value_path, + ) + from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( + WignerDCalculator, + ) + + case = (3, 64, 2, 96, 4, "degree_channel", 1) + value_path_cls = TestSeZMTritonValuePath + conv = value_path_cls._build_conv(self, *case) + fused = make_triton_value_path(conv) + self.assertIsNotNone(fused) + x, cache, radial = value_path_cls._edge_inputs(self, conv, case[0], case[1]) + + calc = WignerDCalculator(case[0], dtype=torch.float32).to("cuda") + # Deterministic rotations; both layouts carry the same entries. + quaternion = self._quaternion(cache.D_full.shape[0], 31) + dense, _ = calc(quaternion) + band = calc.forward_band(quaternion) + + import copy + + # One shared cotangent so both layouts differentiate the same output. + cotangent = None + + def run(rotation_layout): + nonlocal cotangent + source = copy.copy(cache) + source.D_full = dense if rotation_layout == "dense" else None + source.D_packed = None if rotation_layout == "dense" else band + rad = radial.clone().requires_grad_(True) + out, _ = fused(x, source, rad) + if cotangent is None: + cotangent = torch.randn( + out.shape, + device=out.device, + generator=torch.Generator(device=out.device).manual_seed(37), + ) + (grad_radial,) = torch.autograd.grad(out, rad, cotangent) + return out.detach(), grad_radial + + out0, grad0 = run("dense") + out1, grad1 = run("band") + self.assertTrue(torch.equal(out0, out1)) + self.assertTrue(torch.equal(grad0, grad1)) From 788964354b6fcb55abd13c6039e65c85458d2954 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:06:41 +0000 Subject: [PATCH 03/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- deepmd/pt/model/descriptor/sezm_nn/edge_cache.py | 1 + source/tests/pt/model/test_descriptor_sezm_triton.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py index dcb09f4c82..e13833b8ef 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py +++ b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py @@ -28,6 +28,7 @@ from deepmd.pt_expt.kernels.utils import ( use_packed_d_infer, ) + from .utils import ( get_promoted_dtype, nvtx_range, diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index 4191348f0a..be248c7f01 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2165,12 +2165,12 @@ def test_rotate_mix_packed_bitwise(self): @_GPU_KERNELS def test_value_path_packed_bitwise(self): - from deepmd.pt_expt.kernels.triton.sezm.so2_value_path import ( - make_triton_value_path, - ) from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( WignerDCalculator, ) + from deepmd.pt_expt.kernels.triton.sezm.so2_value_path import ( + make_triton_value_path, + ) case = (3, 64, 2, 96, 4, "degree_channel", 1) value_path_cls = TestSeZMTritonValuePath From 93472fa410e48bcfd28c8111ad66872b2950764d Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sun, 11 Oct 2026 22:28:02 +0800 Subject: [PATCH 04/20] perf(pt): op-bound banded Wigner assembly with analytic backward --- .../pt/model/descriptor/sezm_nn/edge_cache.py | 2 +- deepmd/pt/model/descriptor/sezm_nn/wignerd.py | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py index e13833b8ef..29e56d316d 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py +++ b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py @@ -722,7 +722,7 @@ def _build_edge_wigner( # structural rows per degree block, so the dense surfaces are never # materialized. Reference paths that need the dense layout must not # enable the flag. - return None, None, edge_quat, calc.forward_band(edge_quat) + return None, None, edge_quat, calc.forward_band_fused(edge_quat) D_full, Dt_full = wigner_calc(edge_quat) return D_full, Dt_full, edge_quat, None diff --git a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py index bd31b5cf39..515035f38e 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py +++ b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py @@ -712,6 +712,51 @@ def forward_band(self, edge_quaternion: torch.Tensor) -> torch.Tensor: mp_rows.append(block[:, l_degree + 1, :]) return torch.cat(m0_rows + mm_rows + mp_rows, dim=1).contiguous() + def band_op_supported(self) -> bool: + """Return whether the banded assembly can run behind the fused op. + + The op boundary makes the band a save point for the compiled + backward: without it the whole quaternion -> monomial -> product + chain is replayed (once per backward section) instead of the tiny + band being kept. The op covers the CUDA fp32 inference ladder the + production model uses (degrees 1..3 with the Triton monomial + kernels); anything else keeps the plain composition. + """ + return ( + self.lmax in (1, 2, 3) + and not self.training + and bool(getattr(self, "_use_triton_monomials", False)) + and "exp_l2" in getattr(self, "_monomial_exponents_flat", {}) + and "exp_l3" in getattr(self, "_monomial_exponents_flat", {}) + and hasattr(self, "_l2_monomial_coeff") + ) + + def forward_band_fused(self, edge_quaternion: torch.Tensor) -> torch.Tensor: + """ + Build the structural band through the custom-op boundary. + + Same values as :meth:`forward_band`; the normalization stays in the + traced graph so its own cotangent keeps flowing, and the band + production becomes opaque to the compiler, which then saves the + ``(E, 3D-2)`` band for the backward instead of replaying the whole + quaternion chain per backward section. + """ + if not self.band_op_supported(): + return self.forward_band(edge_quaternion) + q_unit = quaternion_normalize( + edge_quaternion.to(dtype=self.dtype), eps=self.eps + ) + return wigner_band_op( + q_unit, + self.l1_perm, + self.l1_sign_outer, + self._l2_monomial_coeff, + self.small_order_kernels.C_l3, + self._monomial_exponents_flat["exp_l2"], + self._monomial_exponents_flat["exp_l3"], + int(self.lmax), + ) + def forward_zonal( self, edge_quaternion: torch.Tensor, @@ -2029,3 +2074,190 @@ def deserialize(cls, data: dict[str, Any]) -> WignerDCalculator: raise NotImplementedError( "WignerDCalculator.deserialize should be called by parent with lmax/dtype" ) + + +def _band_group_offsets(lmax: int) -> tuple[list[int], int]: + """Per-degree offsets of the three band row groups and the full dim. + + The band packs, for degrees ``l = 1..lmax``, the ``m = 0`` row of each + block (preceded by the degree-0 constant), then all ``m = -1`` rows, + then all ``m = +1`` rows; degrees ascend inside every group. + """ + dim = (lmax + 1) ** 2 + widths = [2 * ell + 1 for ell in range(1, lmax + 1)] + bases = [sum(widths[:i]) for i in range(len(widths))] + return bases, dim + + +def _wigner_band_impl( + quat: torch.Tensor, + l1_perm: torch.Tensor, + l1_sign: torch.Tensor, + coeff_l2: torch.Tensor, + c_l3: torch.Tensor, + exp_l2: list[int], + exp_l3: list[int], + lmax: int, +) -> torch.Tensor: + """Band assembly behind an opaque boundary (input must be unit norm). + + Mirrors :meth:`WignerDCalculator.forward_band` for the ``lmax <= 3`` + ladder: the l=1 block is the permuted signed rotation matrix, the + l=2/l=3 blocks are the monomial evaluations contracted with the frozen + coefficient tables. + """ + from deepmd.pt_expt.kernels.triton.sezm.wigner_monomials import ( + wigner_monomials, + ) + + n_edge = quat.shape[0] + m0_rows = [quat.new_ones((n_edge, 1))] + mm_rows: list[torch.Tensor] = [] + mp_rows: list[torch.Tensor] = [] + if lmax >= 1: + rot = quaternion_to_rotation_matrix(quat) + block = rot.index_select(-2, l1_perm).index_select(-1, l1_perm) + block = block * l1_sign + m0_rows.append(block[:, 1, :]) + mm_rows.append(block[:, 0, :]) + mp_rows.append(block[:, 2, :]) + if lmax >= 2: + mono = wigner_monomials(quat, exp_l2, 4) + block = torch.matmul(mono, coeff_l2).view(n_edge, 5, 5) + m0_rows.append(block[:, 2, :]) + mm_rows.append(block[:, 1, :]) + mp_rows.append(block[:, 3, :]) + if lmax >= 3: + mono = wigner_monomials(quat, exp_l3, 6) + block = torch.matmul(mono, c_l3.transpose(0, 1)).view(n_edge, 7, 7) + m0_rows.append(block[:, 3, :]) + mm_rows.append(block[:, 2, :]) + mp_rows.append(block[:, 4, :]) + return torch.cat(m0_rows + mm_rows + mp_rows, dim=1).contiguous() + + +_wigner_band_op = torch.library.triton_op("sezm_triton::wigner_band", mutates_args=())( + _wigner_band_impl +) + + +@_wigner_band_op.register_fake +def _( + quat: torch.Tensor, + l1_perm: torch.Tensor, + l1_sign: torch.Tensor, + coeff_l2: torch.Tensor, + c_l3: torch.Tensor, + exp_l2: list[int], + exp_l3: list[int], + lmax: int, +) -> torch.Tensor: + dim = (lmax + 1) ** 2 + return quat.new_empty((quat.shape[0], 3 * dim - 2)) + + +def _wigner_band_setup_context( + ctx: Any, inputs: tuple[Any, ...], output: torch.Tensor +) -> None: + quat, l1_perm, l1_sign, coeff_l2, c_l3, exp_l2, exp_l3, lmax = inputs + ctx.save_for_backward(quat, l1_perm, l1_sign, coeff_l2, c_l3) + ctx.exp_l2 = exp_l2 + ctx.exp_l3 = exp_l3 + ctx.lmax = lmax + + +def _wigner_band_backward(ctx: Any, grad_band: torch.Tensor) -> tuple: + """Analytic band cotangent: rows -> monomial space -> quaternion. + + The three stored rows of each degree block are contiguous block rows, + so each degree contracts its ``3 x (2l+1)`` gradient slice against the + matching coefficient rows in one small product, hands the result to the + monomial operator's own backward, and the l=1 rows differentiate the + rotation-matrix formula directly. + """ + from deepmd.pt_expt.kernels.triton.sezm.wigner_monomials import ( + _monomials_bwd_op, + ) + + quat, l1_perm, l1_sign, coeff_l2, c_l3 = ctx.saved_tensors + lmax = ctx.lmax + bases, dim = _band_group_offsets(lmax) + grad_quat = torch.zeros_like(quat) + if lmax >= 2: + width = 5 + g2 = torch.cat( + [ + grad_band[ + :, + dim + bases[1] : dim + bases[1] + width, + ], + grad_band[:, 1 + bases[1] : 1 + bases[1] + width], + grad_band[ + :, + 2 * dim - 1 + bases[1] : 2 * dim - 1 + bases[1] + width, + ], + ], + dim=1, + ) + # block rows (1, 2, 3) are flat columns [5, 20) of the product + g_mono = torch.matmul(g2, coeff_l2[:, 5 : 5 + 3 * width].t()) + # the monomial backward runs as its own operator: routing through + # autograd would re-execute the monomial forward inside the traced + # backward and hand back the replay we are removing + grad_quat = grad_quat + _monomials_bwd_op(g_mono, quat, ctx.exp_l2, 4) + if lmax >= 3: + width = 7 + g3 = torch.cat( + [ + grad_band[ + :, + dim + bases[2] : dim + bases[2] + width, + ], + grad_band[:, 1 + bases[2] : 1 + bases[2] + width], + grad_band[ + :, + 2 * dim - 1 + bases[2] : 2 * dim - 1 + bases[2] + width, + ], + ], + dim=1, + ) + # block rows (2, 3, 4) are flat columns [14, 35) of the product + g_mono = torch.matmul(g3, c_l3[2 * width : 5 * width, :]) + grad_quat = grad_quat + _monomials_bwd_op(g_mono, quat, ctx.exp_l3, 6) + with torch.enable_grad(): + q = quat.detach().requires_grad_(True) + if lmax >= 1: + # rows (0, 1, 2) of the l=1 block come from the three groups + g1 = torch.cat( + [ + grad_band[:, dim + bases[0] : dim + bases[0] + 3], + grad_band[:, 1 + bases[0] : 1 + bases[0] + 3], + grad_band[:, 2 * dim - 1 + bases[0] : 2 * dim - 1 + bases[0] + 3], + ], + dim=1, + ).view(-1, 3, 3) + rot = quaternion_to_rotation_matrix(q) + block1 = rot.index_select(-2, l1_perm).index_select(-1, l1_perm) * l1_sign + grad_quat = grad_quat + torch.autograd.grad(block1, q, g1)[0] + return (grad_quat, None, None, None, None, None, None, None) + + +torch.library.register_autograd( + "sezm_triton::wigner_band", + _wigner_band_backward, + setup_context=_wigner_band_setup_context, +) + + +def wigner_band_op( + quat: torch.Tensor, + l1_perm: torch.Tensor, + l1_sign: torch.Tensor, + coeff_l2: torch.Tensor, + c_l3: torch.Tensor, + exp_l2: list[int], + exp_l3: list[int], + lmax: int, +) -> torch.Tensor: + """Public entry for the op-bound banded Wigner assembly.""" + return _wigner_band_op(quat, l1_perm, l1_sign, coeff_l2, c_l3, exp_l2, exp_l3, lmax) From 8ccbbf1cba7ba8fa0f03b37c8e27abea3d0cfd81 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sun, 11 Oct 2026 22:28:02 +0800 Subject: [PATCH 05/20] perf(pt): reuse the packed band m=0 group for the GIE zonal coupling --- deepmd/pt/model/descriptor/sezm.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index 7a3373da5e..9fbbaad30f 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -2134,6 +2134,13 @@ def _build_gie_zonal_coupling( shared = self._shared_wigner_runs(edge_cache, calc.lmax) if shared is not None: return shared + packed = getattr(edge_cache, "D_packed", None) + if packed is not None and calc.lmax <= self.lmax: + # The packed band's m=0 group already carries these entries + # (band columns 1..(lmax+1)^2-1 equal the zonal coupling's + # packed-row order), so no second quaternion -> monomial -> + # block run is scheduled next to the banded assembly. + return packed[:, 1 : (calc.lmax + 1) ** 2] return calc.forward_zonal(self._edge_quaternion(edge_cache), lmin=1) if self.gie_zonal_wigner_calc is None: return None From 85d18f42f8ad044db99fef4b4f4d22adcbee4291 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sun, 11 Oct 2026 22:28:02 +0800 Subject: [PATCH 06/20] test(pt): cover the op-bound band assembly and zonal reuse --- .../pt/model/test_descriptor_sezm_triton.py | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index be248c7f01..d0a59db3ee 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2210,3 +2210,100 @@ def run(rotation_layout): out1, grad1 = run("band") self.assertTrue(torch.equal(out0, out1)) self.assertTrue(torch.equal(grad0, grad1)) + + +class TestSeZMWignerBandOp(unittest.TestCase): + """The op-bound banded assembly and its analytic backward.""" + + def _make_calc(self, lmax): + from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( + WignerDCalculator, + ) + + calc = WignerDCalculator(lmax=lmax, dtype=torch.float32).cuda() + calc.eval() + return calc + + def _op_band(self, calc, quat): + from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( + quaternion_normalize, + wigner_band_op, + ) + + return wigner_band_op( + quaternion_normalize(quat, eps=calc.eps), + calc.l1_perm, + calc.l1_sign_outer, + calc._l2_monomial_coeff, + calc.small_order_kernels.C_l3, + calc._monomial_exponents_flat["exp_l2"], + calc._monomial_exponents_flat["exp_l3"], + int(calc.lmax), + ) + + @_GPU_KERNELS + def test_matches_composition_with_grads(self): + import os + from unittest.mock import patch + + generator = torch.Generator(device="cuda").manual_seed(23) + # the gate is conservative: the op promises the deployed ladder + # (all three degree tables present), lower lmax falls back + for lmax in (3,): + with self.subTest(lmax=lmax): + with patch.dict(os.environ, {"DP_TRITON_INFER": "3"}): + calc = self._make_calc(lmax) + self.assertTrue(calc.band_op_supported()) + quat = torch.randn(4096, 4, device="cuda", generator=generator) + quat = quat / quat.norm(dim=-1, keepdim=True) + quat = quat + 0.01 * torch.randn( + 4096, 4, device="cuda", generator=generator + ) + + q1 = quat.clone().requires_grad_(True) + band = self._op_band(calc, q1) + q2 = quat.clone().requires_grad_(True) + ref = calc.forward_band(q2) + torch.testing.assert_close(band, ref, atol=1e-6, rtol=1e-6) + + cotangent = torch.randn( + ref.shape, + device="cuda", + generator=torch.Generator(device="cuda").manual_seed(37), + ) + g1 = torch.autograd.grad(band, q1, cotangent)[0] + g2 = torch.autograd.grad(ref, q2, cotangent)[0] + torch.testing.assert_close(g1, g2, atol=1e-5, rtol=1e-5) + + @_GPU_KERNELS + def test_compile_matches_eager(self): + generator = torch.Generator(device="cuda").manual_seed(29) + calc = self._make_calc(3) + quat = torch.randn(2048, 4, device="cuda", generator=generator) + quat = quat / quat.norm(dim=-1, keepdim=True) + + def fn(q): + return self._op_band(calc, q) + + q_e = quat.clone().requires_grad_(True) + out_e = fn(q_e) + cot = torch.randn_like(out_e) + g_e = torch.autograd.grad(out_e, q_e, cot)[0] + + q_c = quat.clone().requires_grad_(True) + out_c = torch.compile(fn)(q_c) + g_c = torch.autograd.grad(out_c, q_c, cot)[0] + torch.testing.assert_close(out_c, out_e, atol=1e-6, rtol=1e-6) + torch.testing.assert_close(g_c, g_e, atol=1e-5, rtol=1e-5) + + @_GPU_KERNELS + def test_zonal_coupling_equals_band_m0_group(self): + """The GIE zonal entries are exactly the band's m=0 columns.""" + generator = torch.Generator(device="cuda").manual_seed(31) + calc = self._make_calc(3) + quat = torch.randn(2048, 4, device="cuda", generator=generator) + quat = quat / quat.norm(dim=-1, keepdim=True) + zonal = calc.forward_zonal(quat, lmin=1) + band = calc.forward_band(quat) + dim = (calc.lmax + 1) ** 2 + torch.testing.assert_close(zonal, band[:, 1:dim], atol=1e-6, rtol=1e-6) From e29fbbf182fc080bce7cf666403f58500d1bc880 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:34:02 +0000 Subject: [PATCH 07/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- source/tests/pt/model/test_descriptor_sezm_triton.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index d0a59db3ee..2d343ad4c0 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2244,7 +2244,9 @@ def _op_band(self, calc, quat): @_GPU_KERNELS def test_matches_composition_with_grads(self): import os - from unittest.mock import patch + from unittest.mock import ( + patch, + ) generator = torch.Generator(device="cuda").manual_seed(23) # the gate is conservative: the op promises the deployed ladder From 3ac4f666b1412da2607f4da7bfe31d597f4455be Mon Sep 17 00:00:00 2001 From: longjunhao Date: Wed, 14 Oct 2026 21:27:06 +0800 Subject: [PATCH 08/20] fix(pt): pass the banded-layout flag in the rotate-mix tuning sweep launches --- deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py b/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py index 44c0a91aac..d0e8c7446e 100644 --- a/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py +++ b/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py @@ -902,6 +902,8 @@ def launch(warps: int, stages: int) -> None: CW=c_wide, BC=triton.next_power_of_2(c_wide), RANK=1, + # sweep the dense layout; the banded form shares this entry + PACKED=False, num_warps=warps, num_stages=stages, ) @@ -1002,6 +1004,8 @@ def launch(block_e: int, warps: int, stages: int) -> tuple[torch.Tensor, ...]: CP=triton.next_power_of_2(c_wide), RANK=1, BLOCK_E=block_e, + # sweep the dense layout; the banded form shares this entry + PACKED=False, num_warps=warps, num_stages=stages, ) From 29d4be56ca5c397dd93ab4b5ec0e04bbd6baf64d Mon Sep 17 00:00:00 2001 From: longjunhao Date: Thu, 15 Oct 2026 20:30:26 +0800 Subject: [PATCH 09/20] fix(pt): match the fp16x3 operator's two-output contract in the tuning sweep --- deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py b/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py index d0e8c7446e..2c69572625 100644 --- a/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py +++ b/deepmd/pt_expt/kernels/triton/sezm/sweep_tile_configs.py @@ -2008,7 +2008,9 @@ def wins_against_fp32() -> bool: x_fp32_speed, z_fp32_speed, u_fp32_speed = _mixing_stack_op( u0, alpha, w0_all, w1_all, gw_all, lmax, cf, True ) - x_fp16_speed, z_fp16_speed, u_fp16_speed = _mixing_stack_fp16x3_op( + # the fp16x3 operator returns (x_local, z_all); its backward + # recovers the final identity input internally, unlike the fp32 one + x_fp16_speed, z_fp16_speed = _mixing_stack_fp16x3_op( u0, alpha, w0_all, w1_all, gw_all, lmax, cf, True ) From 25bb484ad181043533c54ce27dd57d7cf65352c7 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Fri, 16 Oct 2026 20:03:44 +0800 Subject: [PATCH 10/20] fix(pt): gate banded Wigner storage on every consumer addressing the band --- deepmd/pt/model/descriptor/sezm.py | 28 +++++++++++++++++++ .../pt/model/descriptor/sezm_nn/edge_cache.py | 12 ++++++++ 2 files changed, 40 insertions(+) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index 9fbbaad30f..676a8c4630 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -132,6 +132,27 @@ @BaseDescriptor.register("SeZM") +def block_supports_packed_wigner(so2_conv: Any) -> bool: + """Whether one block's selected rotation consumers address the band. + + The banded layout serves the Triton value path and the Triton + rotate-mix path (whose kernels address the band directly), together + with the attention aggregation and the GIE coupling that read the same + tensor. Any dense-layout consumer -- the cuTile value path, the fused + CUDA convolution, the CUDA training value path, or the plain SO2 + reference path (no Triton binding at all) -- keeps the dense surfaces. + """ + return ( + getattr(so2_conv, "_cutile_value_path", None) is None + and getattr(so2_conv, "_cuda_conv_fn", None) is None + and getattr(so2_conv, "_cuda_value_train", None) is None + and ( + getattr(so2_conv, "_triton_value_path", None) is not None + or getattr(so2_conv, "_triton_rotate_mix", None) is not None + ) + ) + + @BaseDescriptor.register("sezm") @BaseDescriptor.register("DPA4") @BaseDescriptor.register("dpa4") @@ -1126,6 +1147,11 @@ def __init__( and block.so2_conv._flash_atten_trains for block in self.blocks ) + # Banded storage additionally requires every selected rotation + # consumer to address the band (see ``block_supports_packed_wigner``). + self._packed_wigner_consumers = bool(self.blocks) and all( + block_supports_packed_wigner(block.so2_conv) for block in self.blocks + ) # The envelope and the radial basis are both functions of the pair # distance and are cheap enough that the compiler inlines them into @@ -1427,6 +1453,7 @@ def forward( random_gamma=self.random_gamma and self.training, wigner_calc=self.wigner_calc, build_wigner=self._build_full_wigner(), + packed_wigner=self._packed_wigner_consumers, ) ebed_dim_0 = self.node_init_dim # (node_init_lmax+1)^2 @@ -1743,6 +1770,7 @@ def forward_with_edges( random_gamma=self.random_gamma and self.training, wigner_calc=self.wigner_calc, build_wigner=self._build_full_wigner(), + packed_wigner=self._packed_wigner_consumers, node_partial_exchange=node_partial_exchange, ) diff --git a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py index 29e56d316d..d14ab03cf8 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py +++ b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py @@ -339,6 +339,7 @@ def build_edge_cache( random_gamma: bool, wigner_calc: WignerCalculatorFn, build_wigner: bool = True, + packed_wigner: bool = False, ) -> EdgeFeatureCache: """ Build the global edge cache from DeePMD padded neighbor list. @@ -453,6 +454,7 @@ def build_edge_cache( random_gamma=random_gamma, wigner_calc=wigner_calc, build_full=build_wigner, + packed_wigner=packed_wigner, ) # (E, D, D), (E, D, D), (E, 4) edge_type_feat = build_edge_type_feat(type_ebed, src, dst) # (E, C) @@ -494,6 +496,7 @@ def build_edge_cache_from_edges( random_gamma: bool, wigner_calc: WignerCalculatorFn, build_wigner: bool = True, + packed_wigner: bool = False, node_partial_exchange: Callable[[torch.Tensor], torch.Tensor] | None = None, fused_radial: FusedRadialFn | None = None, fused_wigner: WignerCalculatorFn | None = None, @@ -603,6 +606,7 @@ def build_edge_cache_from_edges( random_gamma=random_gamma, wigner_calc=fused_wigner if fused_wigner is not None else wigner_calc, build_full=build_wigner, + packed_wigner=packed_wigner, ) # (E, D, D), (E, D, D), (E, 4) # === Step 5. Edge type features === @@ -660,6 +664,7 @@ def _build_edge_wigner( random_gamma: bool, wigner_calc: WignerCalculatorFn, build_full: bool = True, + packed_wigner: bool = False, ) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor]: """ Build packed Wigner-D blocks from edge vectors. @@ -682,6 +687,12 @@ def _build_edge_wigner( False (all message-passing blocks take the Cartesian path), only the quaternion is returned and the blocks are ``None``; the geometric initial embedding reconstructs the zonal coupling from the quaternion. + packed_wigner + Whether every selected rotation consumer addresses the banded + layout. The banded storage is only chosen when this holds; any + dense-layout consumer (the cuTile value path, the fused CUDA + convolution, or the plain SO2 reference path) keeps the dense + surfaces even with ``DP_PACKED_D_INFER`` set. Returns ------- @@ -714,6 +725,7 @@ def _build_edge_wigner( calc = getattr(wigner_calc, "__self__", wigner_calc) if ( use_packed_d_infer() + and packed_wigner and callable(getattr(calc, "forward_band", None)) and calc.band_supported() ): From b615b426d3b3be3c7a84956af641aa0b9e5de381 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Fri, 16 Oct 2026 20:03:44 +0800 Subject: [PATCH 11/20] test(pt): cover the packed-Wigner consumer gate matrix --- .../pt/model/test_descriptor_sezm_triton.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index 2d343ad4c0..05d4d2ab9e 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2051,6 +2051,55 @@ def test_tune_missing_configs_runs_the_real_flash_bwd_sweep(self): self.assertTrue(tc.has_tile_config("flash_bwd_block", key)) +class TestSeZMPackedWignerConsumerGate(unittest.TestCase): + """The banded layout requires every rotation consumer to address it.""" + + def _conv(self, **kw): + from types import SimpleNamespace + + base = { + "_cutile_value_path": None, + "_cuda_conv_fn": None, + "_cuda_value_train": None, + "_triton_value_path": None, + "_triton_rotate_mix": None, + } + base.update(kw) + return SimpleNamespace(**base) + + def test_gate_matrix(self): + from deepmd.pt.model.descriptor.sezm import ( + block_supports_packed_wigner, + ) + + self.assertTrue( + block_supports_packed_wigner(self._conv(_triton_value_path=object())) + ) + self.assertTrue( + block_supports_packed_wigner(self._conv(_triton_rotate_mix=object())) + ) + # cuTile, the fused CUDA convolution and the CUDA training path all + # address the dense layout and must block the banded storage + self.assertFalse( + block_supports_packed_wigner( + self._conv(_triton_value_path=object(), _cutile_value_path=object()) + ) + ) + self.assertFalse( + block_supports_packed_wigner( + self._conv(_triton_value_path=object(), _cuda_conv_fn=object()) + ) + ) + self.assertFalse( + block_supports_packed_wigner( + self._conv(_triton_value_path=object(), _cuda_value_train=object()) + ) + ) + # no accelerated binding: the plain SO2 reference path reads the + # dense surface + self.assertFalse(block_supports_packed_wigner(self._conv())) + + if __name__ == "__main__": unittest.main() From 8605f6c9df9a3514e6f670903cf80b1feee92d6e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:04:56 +0000 Subject: [PATCH 12/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- source/tests/pt/model/test_descriptor_sezm_triton.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index 05d4d2ab9e..79fdc1d931 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2055,7 +2055,9 @@ class TestSeZMPackedWignerConsumerGate(unittest.TestCase): """The banded layout requires every rotation consumer to address it.""" def _conv(self, **kw): - from types import SimpleNamespace + from types import ( + SimpleNamespace, + ) base = { "_cutile_value_path": None, From 3291de47ab9119557584eb5cc2c1c39528bafc44 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sun, 18 Oct 2026 17:48:33 +0800 Subject: [PATCH 13/20] fix(pt): evaluate the packed-Wigner gate per cache build against current consumers The gate is now a call-time check over the currently selected forward and inverse consumers: without a bound flash aggregation the message is rotated back through the dense inverse matrix, so no-attention and projected-attention configurations keep the dense surfaces; training mode reads the training level and its rotate-mix binding instead of a construction-time boolean, so level combinations like inference 2 / training 0 followed by .train() no longer pass a stale verdict. The gate matrix tests now cover both mode cases and the aggregation routes. --- deepmd/pt/model/descriptor/sezm.py | 70 ++++++++++----- .../pt/model/test_descriptor_sezm_triton.py | 87 +++++++++++++++---- 2 files changed, 121 insertions(+), 36 deletions(-) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index 676a8c4630..b0ea412c71 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -67,6 +67,8 @@ ) from deepmd.pt_expt.kernels.utils import ( cuda_infer_level, + triton_infer_level, + triton_train_level, use_amp_infer, ) from deepmd.utils.bridging import ( @@ -132,24 +134,41 @@ @BaseDescriptor.register("SeZM") -def block_supports_packed_wigner(so2_conv: Any) -> bool: - """Whether one block's selected rotation consumers address the band. +def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: + """Whether one block's *currently selected* rotation consumers address + the band. The banded layout serves the Triton value path and the Triton - rotate-mix path (whose kernels address the band directly), together - with the attention aggregation and the GIE coupling that read the same - tensor. Any dense-layout consumer -- the cuTile value path, the fused - CUDA convolution, the CUDA training value path, or the plain SO2 - reference path (no Triton binding at all) -- keeps the dense surfaces. + rotate-mix path (whose kernels address the band directly) together + with the Triton flash aggregation and the GIE coupling that read the + same tensor. Every dense-layout consumer keeps the dense surfaces: + the cuTile value path, the fused CUDA convolution, the CUDA training + value path, the plain SO2 reference path (no Triton binding selected + at the *current* level and mode), and the aggregation routes that + rotate messages back through ``Dt_full`` (no attention heads, or an + attention layout with value/output projections, where the flash + operator does not bind). The check is evaluated per cache build, so + mode and level changes between builds are honored. """ - return ( - getattr(so2_conv, "_cutile_value_path", None) is None - and getattr(so2_conv, "_cuda_conv_fn", None) is None - and getattr(so2_conv, "_cuda_value_train", None) is None - and ( - getattr(so2_conv, "_triton_value_path", None) is not None - or getattr(so2_conv, "_triton_rotate_mix", None) is not None - ) + if getattr(so2_conv, "_cutile_value_path", None) is not None: + return False + if getattr(so2_conv, "_cuda_conv_fn", None) is not None: + return False + if getattr(so2_conv, "_cuda_value_train", None) is not None: + return False + # Inverse-rotation consumer: without a bound flash aggregation the + # message is rotated back through the dense inverse matrix. + if getattr(so2_conv, "_flash_atten_fn", None) is None: + return False + level = triton_train_level() if training else triton_infer_level() + if level < 1: + return False + # Forward-rotation consumer selected at the current level. + if training: + return getattr(so2_conv, "_triton_rotate_mix", None) is not None + return getattr(so2_conv, "_triton_value_path", None) is not None or ( + getattr(so2_conv, "_triton_value_path", None) is None + and getattr(so2_conv, "_triton_rotate_mix", None) is not None ) @@ -1148,9 +1167,11 @@ def __init__( for block in self.blocks ) # Banded storage additionally requires every selected rotation - # consumer to address the band (see ``block_supports_packed_wigner``). - self._packed_wigner_consumers = bool(self.blocks) and all( - block_supports_packed_wigner(block.so2_conv) for block in self.blocks + # consumer to address the band; the decision is re-evaluated at + # each cache build so train/eval and level changes are honored + # (see ``block_supports_packed_wigner``). + self._blocks_have_so2_conv = bool(self.blocks) and all( + hasattr(block, "so2_conv") for block in self.blocks ) # The envelope and the radial basis are both functions of the pair @@ -1453,7 +1474,7 @@ def forward( random_gamma=self.random_gamma and self.training, wigner_calc=self.wigner_calc, build_wigner=self._build_full_wigner(), - packed_wigner=self._packed_wigner_consumers, + packed_wigner=self._packed_wigner_ok(), ) ebed_dim_0 = self.node_init_dim # (node_init_lmax+1)^2 @@ -1770,7 +1791,7 @@ def forward_with_edges( random_gamma=self.random_gamma and self.training, wigner_calc=self.wigner_calc, build_wigner=self._build_full_wigner(), - packed_wigner=self._packed_wigner_consumers, + packed_wigner=self._packed_wigner_ok(), node_partial_exchange=node_partial_exchange, ) @@ -2090,6 +2111,15 @@ def _edge_quaternion(self, edge_cache: EdgeFeatureCache) -> torch.Tensor: ) return edge_quat + def _packed_wigner_ok(self) -> bool: + """Every block's currently selected consumers address the band.""" + if not self._blocks_have_so2_conv: + return False + return all( + block_supports_packed_wigner(block.so2_conv, self.training) + for block in self.blocks + ) + def _build_full_wigner(self) -> bool: """Return whether the active execution path needs dense Wigner blocks.""" if not self._need_full_wigner: diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index 79fdc1d931..ac2d270971 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -23,6 +23,7 @@ """ import math +import os import typing import unittest from unittest import ( @@ -2052,7 +2053,7 @@ def test_tune_missing_configs_runs_the_real_flash_bwd_sweep(self): class TestSeZMPackedWignerConsumerGate(unittest.TestCase): - """The banded layout requires every rotation consumer to address it.""" + """The banded layout requires every *currently selected* consumer.""" def _conv(self, **kw): from types import ( @@ -2065,41 +2066,95 @@ def _conv(self, **kw): "_cuda_value_train": None, "_triton_value_path": None, "_triton_rotate_mix": None, + "_flash_atten_fn": None, } base.update(kw) return SimpleNamespace(**base) + def _with_levels(self, infer, train): + old_i = os.environ.get("DP_TRITON_INFER") + old_t = os.environ.get("DP_TRITON_TRAIN") + os.environ["DP_TRITON_INFER"] = infer + os.environ["DP_TRITON_TRAIN"] = train + + def restore(): + for key, old in (("DP_TRITON_INFER", old_i), ("DP_TRITON_TRAIN", old_t)): + if old is None: + os.environ.pop(key, None) + else: + os.environ[key] = old + + self.addCleanup(restore) + def test_gate_matrix(self): from deepmd.pt.model.descriptor.sezm import ( block_supports_packed_wigner, ) - self.assertTrue( - block_supports_packed_wigner(self._conv(_triton_value_path=object())) - ) - self.assertTrue( - block_supports_packed_wigner(self._conv(_triton_rotate_mix=object())) + self._with_levels("2", "0") + # Dense-layout value/aggregation consumers block the band. + self.assertFalse( + block_supports_packed_wigner(self._conv(_triton_value_path=object()), False) ) - # cuTile, the fused CUDA convolution and the CUDA training path all - # address the dense layout and must block the banded storage + # The envelope aggregation (no attention heads) rotates back + # through Dt_full even with a packed-capable forward path. self.assertFalse( block_supports_packed_wigner( - self._conv(_triton_value_path=object(), _cutile_value_path=object()) + self._conv( + _triton_value_path=object(), + _flash_atten_fn=None, + ), + False, ) ) - self.assertFalse( + # cuTile, the fused CUDA convolution and the CUDA training path all + # address the dense layout and must block the banded storage. + for dense in ( + "_cutile_value_path", + "_cuda_conv_fn", + "_cuda_value_train", + ): + with self.subTest(dense=dense): + kw = { + "_triton_value_path": object(), + "_flash_atten_fn": object(), + dense: object(), + } + self.assertFalse(block_supports_packed_wigner(self._conv(**kw), False)) + # The plain SO2 reference path (no Triton binding at all) reads the + # dense surface. + self.assertFalse(block_supports_packed_wigner(self._conv(), False)) + # A fully packed-capable selection passes in eval. + self.assertTrue( block_supports_packed_wigner( - self._conv(_triton_value_path=object(), _cuda_conv_fn=object()) + self._conv(_triton_value_path=object(), _flash_atten_fn=object()), + False, ) ) - self.assertFalse( + self.assertTrue( block_supports_packed_wigner( - self._conv(_triton_value_path=object(), _cuda_value_train=object()) + self._conv(_triton_rotate_mix=object(), _flash_atten_fn=object()), + False, ) ) - # no accelerated binding: the plain SO2 reference path reads the - # dense surface - self.assertFalse(block_supports_packed_wigner(self._conv())) + + def test_gate_follows_mode_and_levels(self): + """The gate is evaluated against the current mode and levels.""" + from deepmd.pt.model.descriptor.sezm import ( + block_supports_packed_wigner, + ) + + packed_conv = self._conv(_triton_value_path=object(), _flash_atten_fn=object()) + # Inference level 2, training level 0: eval passes, train rejects. + self._with_levels("2", "0") + self.assertTrue(block_supports_packed_wigner(packed_conv, False)) + self.assertFalse(block_supports_packed_wigner(packed_conv, True)) + # Inference level 0, training level 1: eval rejects; a + # training-bound rotate-mix block passes in train mode. + self._with_levels("0", "1") + self.assertFalse(block_supports_packed_wigner(packed_conv, False)) + train_conv = self._conv(_triton_rotate_mix=object(), _flash_atten_fn=object()) + self.assertTrue(block_supports_packed_wigner(train_conv, True)) if __name__ == "__main__": From 813b37c418a851c11427a8c4809ad56a258d8ae6 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sun, 18 Oct 2026 18:16:26 +0800 Subject: [PATCH 14/20] fix(pt): derive the packed-Wigner gate from the per-block captured levels Dispatch resolves Triton levels through the values captured on each block at construction (active_triton_level), so the gate now reads those same numbers instead of the ambient environment: a block built inside an inference-2/training-0 scope can no longer pass the gate in training mode after the ambient training level changes, and the converse eval case is covered as well. Training-mode aggregation additionally requires the flash operator to have been bound for training (_flash_atten_trains), matching what the training dispatch actually uses. --- deepmd/pt/model/descriptor/sezm.py | 30 ++++++----- .../pt/model/test_descriptor_sezm_triton.py | 51 +++++++++++++++---- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index b0ea412c71..65c3a1aaf9 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -67,8 +67,6 @@ ) from deepmd.pt_expt.kernels.utils import ( cuda_infer_level, - triton_infer_level, - triton_train_level, use_amp_infer, ) from deepmd.utils.bridging import ( @@ -143,12 +141,12 @@ def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: with the Triton flash aggregation and the GIE coupling that read the same tensor. Every dense-layout consumer keeps the dense surfaces: the cuTile value path, the fused CUDA convolution, the CUDA training - value path, the plain SO2 reference path (no Triton binding selected - at the *current* level and mode), and the aggregation routes that - rotate messages back through ``Dt_full`` (no attention heads, or an - attention layout with value/output projections, where the flash - operator does not bind). The check is evaluated per cache build, so - mode and level changes between builds are honored. + value path, the plain SO2 reference path, and the aggregation routes + that rotate messages back through ``Dt_full`` (no attention heads, + a projected-attention layout, or a training flash that did not bind + for training). Levels derive from the per-block values captured at + construction -- the same numbers ``active_triton_level`` resolves + dispatch with -- and the train/eval mode is read per cache build. """ if getattr(so2_conv, "_cutile_value_path", None) is not None: return False @@ -157,13 +155,21 @@ def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: if getattr(so2_conv, "_cuda_value_train", None) is not None: return False # Inverse-rotation consumer: without a bound flash aggregation the - # message is rotated back through the dense inverse matrix. - if getattr(so2_conv, "_flash_atten_fn", None) is None: + # message is rotated back through the dense inverse matrix. In + # training the aggregation is packed-capable only when the flash + # operator was bound for training too. + flash = getattr(so2_conv, "_flash_atten_fn", None) + if flash is None: return False - level = triton_train_level() if training else triton_infer_level() + if training and not getattr(so2_conv, "_flash_atten_trains", False): + return False + # Dispatch resolves against the per-block levels captured at + # construction (``active_triton_level``), not the ambient environment; + # eligibility must derive from the same captured numbers. + level = so2_conv.triton_train_level if training else so2_conv.triton_infer_level if level < 1: return False - # Forward-rotation consumer selected at the current level. + # Forward-rotation consumer selected at the captured level. if training: return getattr(so2_conv, "_triton_rotate_mix", None) is not None return getattr(so2_conv, "_triton_value_path", None) is not None or ( diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index ac2d270971..17ee7242ff 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2067,6 +2067,9 @@ def _conv(self, **kw): "_triton_value_path": None, "_triton_rotate_mix": None, "_flash_atten_fn": None, + "_flash_atten_trains": True, + "triton_infer_level": 2, + "triton_train_level": 0, } base.update(kw) return SimpleNamespace(**base) @@ -2138,23 +2141,51 @@ def test_gate_matrix(self): ) ) - def test_gate_follows_mode_and_levels(self): - """The gate is evaluated against the current mode and levels.""" + def test_gate_follows_mode_and_captured_levels(self): + """The gate derives from the per-block captured levels, like dispatch.""" from deepmd.pt.model.descriptor.sezm import ( block_supports_packed_wigner, ) - packed_conv = self._conv(_triton_value_path=object(), _flash_atten_fn=object()) - # Inference level 2, training level 0: eval passes, train rejects. - self._with_levels("2", "0") + # Captured inference 2 / training 0: eval passes, train rejects + # (the captured training level selects the dense path regardless + # of what the ambient environment now says). + packed_conv = self._conv( + _triton_value_path=object(), + _flash_atten_fn=object(), + _flash_atten_trains=True, + triton_infer_level=2, + triton_train_level=0, + ) + self._with_levels("1", "1") # ambient disagrees on purpose self.assertTrue(block_supports_packed_wigner(packed_conv, False)) self.assertFalse(block_supports_packed_wigner(packed_conv, True)) - # Inference level 0, training level 1: eval rejects; a - # training-bound rotate-mix block passes in train mode. - self._with_levels("0", "1") - self.assertFalse(block_supports_packed_wigner(packed_conv, False)) - train_conv = self._conv(_triton_rotate_mix=object(), _flash_atten_fn=object()) + # Captured inference 0 / training 1: eval rejects; a training- + # bound rotate-mix block whose flash binds for training passes. + train_conv = self._conv( + _triton_rotate_mix=object(), + _flash_atten_fn=object(), + _flash_atten_trains=True, + triton_infer_level=0, + triton_train_level=1, + ) + self.assertFalse(block_supports_packed_wigner(train_conv, False)) self.assertTrue(block_supports_packed_wigner(train_conv, True)) + # Training flash bound for inference only: the training-mode + # aggregation rotates back through the dense inverse, so the band + # is rejected even with a training-bound rotate-mix forward. + self.assertFalse( + block_supports_packed_wigner( + self._conv( + _triton_rotate_mix=object(), + _flash_atten_fn=object(), + _flash_atten_trains=False, + triton_infer_level=2, + triton_train_level=1, + ), + True, + ) + ) if __name__ == "__main__": From d2616eb2ef0d3484aea8d08897a0e4955c5cdc80 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Wed, 21 Oct 2026 20:17:41 +0800 Subject: [PATCH 15/20] fix(pt): restore SeZM descriptor registration The module-level packed-Wigner gate helper accidentally carried the register("SeZM") decorator, so the descriptor registry mapped "SeZM" to a plain function and factory construction raised TypeError. Move the decorator back onto DescrptSeZM and cover construction through the registry for all four type keys. --- deepmd/pt/model/descriptor/sezm.py | 2 +- source/tests/pt/model/test_descriptor_sezm.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index 65c3a1aaf9..b197444dff 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -131,7 +131,6 @@ ) -@BaseDescriptor.register("SeZM") def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: """Whether one block's *currently selected* rotation consumers address the band. @@ -178,6 +177,7 @@ def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: ) +@BaseDescriptor.register("SeZM") @BaseDescriptor.register("sezm") @BaseDescriptor.register("DPA4") @BaseDescriptor.register("dpa4") diff --git a/source/tests/pt/model/test_descriptor_sezm.py b/source/tests/pt/model/test_descriptor_sezm.py index 5bf465da0f..62a85ea8d2 100644 --- a/source/tests/pt/model/test_descriptor_sezm.py +++ b/source/tests/pt/model/test_descriptor_sezm.py @@ -699,6 +699,24 @@ def test_forward_with_descriptor_variants(self) -> None: with self.subTest(mode=name): self._assert_forward_backward_smoke(**model_kwargs) + def test_registry_construction(self) -> None: + """Every type key must resolve to the descriptor class in the registry. + + A module-level helper once ended up decorated with + ``register("SeZM")``, which silently replaced the class with a + plain function in the registry and broke factory construction. + """ + from deepmd.pt.model.descriptor.base_descriptor import ( + BaseDescriptor, + ) + + for type_name in ("SeZM", "sezm", "DPA4", "dpa4"): + with self.subTest(type=type_name): + cls = BaseDescriptor.get_class_by_type(type_name) + self.assertIs(cls, DescrptSeZM) + model = cls(**_descriptor_kwargs()) + self.assertIsInstance(model, DescrptSeZM) + def test_fixed_basis_types_freeze_the_basis(self) -> None: """``/fix`` basis types keep the basis parameters out of training only.""" for family in ("bessel", "gaussian"): From 0eb7874e367b80a17ee71b917eb752b0e1d20b63 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Thu, 22 Oct 2026 20:41:43 +0800 Subject: [PATCH 16/20] fix(pt): keep the packed Wigner layout inference-only Training builds now always keep the dense Wigner surfaces: the registered parameter-gradient consumers (the channel-basis backward of the rotate-mix operator among them) address the dense surface, so a training cache must never carry the band even when the bound kernels could read it. The gate returns False for training before any consumer check, and the gate matrix test asserts training rejection for packed-capable blocks as well. End-to-end coverage with DP_PACKED_D_INFER actually set drives the whole chain (edge-cache build, per-block gate, consumers): the flag-on eval cache carries the (E, 3D-2) band and never materializes the dense surfaces, the same model in training builds the dense layout, and the flag-off outputs are bitwise-equal. The unsupported branches are covered as well: forward_band_fused falls back to the plain composition when the op gate rejects (training), and forward_band raises for lmax >= poly_lmin. Docstrings: wigner_band_op gains full parameter documentation, the packed_wigner contract lists the full exclusion set, _build_edge_wigner documents the fourth return value (D_packed), the m = -1 slot range is corrected to [D, 2D - 1), and the flag description no longer claims an on-demand unpack that does not exist. --- deepmd/pt/model/descriptor/sezm.py | 35 +++--- .../pt/model/descriptor/sezm_nn/edge_cache.py | 25 ++-- deepmd/pt/model/descriptor/sezm_nn/wignerd.py | 34 +++++- deepmd/pt_expt/kernels/utils.py | 9 +- source/tests/pt/model/test_descriptor_sezm.py | 107 ++++++++++++++++++ .../pt/model/test_descriptor_sezm_triton.py | 42 +++++-- 6 files changed, 213 insertions(+), 39 deletions(-) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index b197444dff..345a8f4fdc 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -133,20 +133,25 @@ def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: """Whether one block's *currently selected* rotation consumers address - the band. + the band (inference only). The banded layout serves the Triton value path and the Triton rotate-mix path (whose kernels address the band directly) together with the Triton flash aggregation and the GIE coupling that read the same tensor. Every dense-layout consumer keeps the dense surfaces: - the cuTile value path, the fused CUDA convolution, the CUDA training - value path, the plain SO2 reference path, and the aggregation routes - that rotate messages back through ``Dt_full`` (no attention heads, - a projected-attention layout, or a training flash that did not bind - for training). Levels derive from the per-block values captured at - construction -- the same numbers ``active_triton_level`` resolves - dispatch with -- and the train/eval mode is read per cache build. + the cuTile value path, the fused CUDA convolution, the plain SO2 + reference path, and the aggregation routes that rotate messages back + through ``Dt_full`` (no attention heads or a projected-attention + layout). Training always keeps the dense layout: the registered + parameter-gradient consumers (the channel-basis backward of the + rotate-mix operator among them) address the dense surface, so a + training cache must never carry the band even when the bound kernels + could read it. The level derives from the per-block value captured + at construction -- the same number ``active_triton_level`` resolves + dispatch with -- and the gate is evaluated per cache build. """ + if training: + return False if getattr(so2_conv, "_cutile_value_path", None) is not None: return False if getattr(so2_conv, "_cuda_conv_fn", None) is not None: @@ -154,23 +159,15 @@ def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: if getattr(so2_conv, "_cuda_value_train", None) is not None: return False # Inverse-rotation consumer: without a bound flash aggregation the - # message is rotated back through the dense inverse matrix. In - # training the aggregation is packed-capable only when the flash - # operator was bound for training too. - flash = getattr(so2_conv, "_flash_atten_fn", None) - if flash is None: - return False - if training and not getattr(so2_conv, "_flash_atten_trains", False): + # message is rotated back through the dense inverse matrix. + if getattr(so2_conv, "_flash_atten_fn", None) is None: return False # Dispatch resolves against the per-block levels captured at # construction (``active_triton_level``), not the ambient environment; # eligibility must derive from the same captured numbers. - level = so2_conv.triton_train_level if training else so2_conv.triton_infer_level - if level < 1: + if so2_conv.triton_infer_level < 1: return False # Forward-rotation consumer selected at the captured level. - if training: - return getattr(so2_conv, "_triton_rotate_mix", None) is not None return getattr(so2_conv, "_triton_value_path", None) is not None or ( getattr(so2_conv, "_triton_value_path", None) is None and getattr(so2_conv, "_triton_rotate_mix", None) is not None diff --git a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py index d14ab03cf8..93e131ab08 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py +++ b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py @@ -665,7 +665,12 @@ def _build_edge_wigner( wigner_calc: WignerCalculatorFn, build_full: bool = True, packed_wigner: bool = False, -) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor]: +) -> tuple[ + torch.Tensor | None, + torch.Tensor | None, + torch.Tensor, + torch.Tensor | None, +]: """ Build packed Wigner-D blocks from edge vectors. @@ -690,16 +695,22 @@ def _build_edge_wigner( packed_wigner Whether every selected rotation consumer addresses the banded layout. The banded storage is only chosen when this holds; any - dense-layout consumer (the cuTile value path, the fused CUDA - convolution, or the plain SO2 reference path) keeps the dense - surfaces even with ``DP_PACKED_D_INFER`` set. + dense-layout consumer keeps the dense surfaces even with + ``DP_PACKED_D_INFER`` set. See + :func:`deepmd.pt.model.descriptor.sezm.block_supports_packed_wigner` + for the full exclusion set (the cuTile value path, the fused CUDA + convolution, the CUDA training value path, the plain SO2 reference + path, and the aggregation routes that rotate messages back through + ``Dt_full``; training is dense by construction). Returns ------- - tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor] + tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor, torch.Tensor | None] Packed Wigner-D matrices ``(D_full, Dt_full)`` with shape ``(E, D, D)`` - (or ``None`` when ``build_full`` is False) and the quaternion used to - build them with shape ``(E, 4)``. + (or ``None`` when ``build_full`` is False), the quaternion used to + build them with shape ``(E, 4)``, and the banded storage ``D_packed`` + with shape ``(E, 3 * D - 2)`` when the banded layout was selected + (``None`` otherwise, including every training build). """ # === Step 1. Build edge-aligned quaternions === edge_quat = build_edge_quaternion( diff --git a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py index 515035f38e..142f5c18c9 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py +++ b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py @@ -670,7 +670,7 @@ def forward_band(self, edge_quaternion: torch.Tensor) -> torch.Tensor: consumers read: - slots ``[0, D)``: ``D[l^2 + l, d]`` (the ``m = 0`` row), - - slots ``[D, 2D - 2)``: ``D[l^2 + l - 1, d]`` (``m = -1`` row, + - slots ``[D, 2D - 1)``: ``D[l^2 + l - 1, d]`` (``m = -1`` row, degrees ``l >= 1`` only, stored at slot ``D + d - 1``), - slots ``[2D - 1, 3D - 2)``: ``D[l^2 + l + 1, d]`` (``m = +1`` row, stored at slot ``2D + d - 2``). @@ -2259,5 +2259,35 @@ def wigner_band_op( exp_l3: list[int], lmax: int, ) -> torch.Tensor: - """Public entry for the op-bound banded Wigner assembly.""" + """Public entry for the op-bound banded Wigner band assembly. + + Same values as :meth:`WignerDCalculator.forward_band` behind the + ``sezm_triton::wigner_band`` operator boundary, so the compiled graph + saves the ``(E, 3 * dim_full - 2)`` band for the backward instead of + replaying the quaternion -> monomial -> product chain. + + Parameters + ---------- + quat + Unit quaternions with shape ``(E, 4)``. + l1_perm + Degree-1 row permutation index with shape ``(3,)``. + l1_sign_outer + Sign outer product for the degree-1 block with shape ``(3, 3)``. + coeff_l2 + Degree-2 monomial coefficient table. + c_l3 + Degree-3 case-coefficient table (``CaseCoefficients.C_l3``). + exp_l2 + Flattened degree-2 monomial exponents. + exp_l3 + Flattened degree-3 monomial exponents. + lmax + Maximum spherical harmonic degree; 1..3. + + Returns + ------- + torch.Tensor + Band tensor with shape ``(E, 3 * (lmax + 1)**2 - 2)``. + """ return _wigner_band_op(quat, l1_perm, l1_sign, coeff_l2, c_l3, exp_l2, exp_l3, lmax) diff --git a/deepmd/pt_expt/kernels/utils.py b/deepmd/pt_expt/kernels/utils.py index ca0b796bad..156deddbfc 100644 --- a/deepmd/pt_expt/kernels/utils.py +++ b/deepmd/pt_expt/kernels/utils.py @@ -390,9 +390,12 @@ def use_packed_d_infer() -> bool: configuration supports it), the edge cache stores only the ``3 * D - 2`` structural band entries of the block-diagonal Wigner matrices per edge instead of the dense ``(E, D, D)`` surfaces; the fused Triton consumers - (rotate-mix, flash aggregation) address the band directly, and paths that - still need the dense layout unpack it on demand. The stored values are the - same floats, so consumer outputs are bit-identical to the dense mode. + (rotate-mix, flash aggregation) address the band directly. There is no + unpack: the dense surfaces simply never exist in that mode, the Triton + launchers reject a banded input on the dense-addressing paths, and + reference (non-Triton) execution must not enable the flag. The stored + values are the same floats the dense layout keeps at those positions, so + consumer outputs are bit-identical to the dense mode. Returns ------- diff --git a/source/tests/pt/model/test_descriptor_sezm.py b/source/tests/pt/model/test_descriptor_sezm.py index 62a85ea8d2..e59861aaf5 100644 --- a/source/tests/pt/model/test_descriptor_sezm.py +++ b/source/tests/pt/model/test_descriptor_sezm.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: LGPL-3.0-or-later +import importlib.util import itertools import math import os @@ -3134,5 +3135,111 @@ def test_sfpg_leaks_reopen_when_gate_is_disabled(self) -> None: ) +@unittest.skipUnless( + torch.cuda.is_available() and importlib.util.find_spec("triton") is not None, + "the packed Wigner layout engages on CUDA with Triton", +) +class TestSeZMPackedWignerEndToEnd(_SeZMTestCase): + """End-to-end coverage with ``DP_PACKED_D_INFER`` actually set. + + Drives the whole chain with the flag on: the edge-cache build, the + per-block gate in ``sezm.py``, and the consumers in ``so2.py`` / + ``embedding.py`` all run against the banded storage, and the outputs + must match the same model built through the dense layout. + """ + + def _kwargs(self): + return { + "rcut": 3.0, + "sel": [1, 1], + "ntypes": 2, + "l_schedule": [1, 1], + "mmax": 1, + "channels": 32, + "focus_dim": 32, + "n_focus": 2, + "n_radial": 3, + "radial_mlp": [6], + "ffn_neurons": 8, + "ffn_blocks": 1, + "random_gamma": False, + "n_atten_head": 1, + "mlp_bias": False, + "precision": "float32", + "trainable": True, + "radial_so2_mode": "degree_channel", + "radial_so2_rank": 2, + } + + def test_flag_on_builds_band_and_matches_dense(self): + import deepmd.pt.model.descriptor.sezm_nn.edge_cache as edge_cache_mod + + saved_flag = os.environ.get("DP_PACKED_D_INFER") + saved_level = os.environ.get("DP_TRITON_INFER") + os.environ["DP_PACKED_D_INFER"] = "1" + os.environ["DP_TRITON_INFER"] = "2" + + def restore(): + for key, old in ( + ("DP_PACKED_D_INFER", saved_flag), + ("DP_TRITON_INFER", saved_level), + ): + if old is None: + os.environ.pop(key, None) + else: + os.environ[key] = old + + self.addCleanup(restore) + + coord = torch.tensor( + [[[0.0, 0.0, 0.0], [0.0, 0.0, 1.5]]], dtype=torch.float32, device="cuda" + ) + atype = torch.tensor([[0, 1]], dtype=torch.long, device="cuda") + nlist = torch.tensor([[[0, 1], [1, -1]]], dtype=torch.long, device="cuda") + + records = [] + real_build = edge_cache_mod._build_edge_wigner + + def spy(*args, **kwargs): + built = real_build(*args, **kwargs) + records.append(tuple(None if t is None else tuple(t.shape) for t in built)) + return built + + model = DescrptSeZM(**self._kwargs()).cuda() + model.eval() + + with mock.patch.object(edge_cache_mod, "_build_edge_wigner", spy): + with torch.no_grad(): + packed_out = model(coord, atype, nlist, mapping=None, comm_dict=None)[0] + # The flag-on cache carries the band and never materializes the + # dense surfaces: D = (E, D_dim, D_dim) with D_dim = (lmax+1)^2, + # band = (E, 3 * D_dim - 2). + d_dense, dt_dense, _quat, d_packed = records[-1] + self.assertIsNone(d_dense) + self.assertIsNone(dt_dense) + self.assertIsNotNone(d_packed) + self.assertEqual(d_packed, (3, 10)) + + # Training keeps the dense layout even with the flag on: the + # parameter-gradient consumers address the dense surface. + model.train() + model(coord, atype, nlist, mapping=None, comm_dict=None) + d_dense_t, dt_dense_t, _quat_t, d_packed_t = records[-1] + self.assertIsNone(d_packed_t) + self.assertIsNotNone(d_dense_t) + self.assertIsNotNone(dt_dense_t) + model.eval() + + # Same model, flag off: the dense layout through the same + # consumers, bitwise-equal outputs. + os.environ.pop("DP_PACKED_D_INFER", None) + with torch.no_grad(): + dense_out = model(coord, atype, nlist, mapping=None, comm_dict=None)[0] + d_dense_o, _dt, _quat_o, d_packed_o = records[-1] + self.assertIsNone(d_packed_o) + self.assertIsNotNone(d_dense_o) + self.assertTrue(torch.equal(packed_out, dense_out)) + + if __name__ == "__main__": unittest.main() diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index 17ee7242ff..6a3c10a81d 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2160,8 +2160,11 @@ def test_gate_follows_mode_and_captured_levels(self): self._with_levels("1", "1") # ambient disagrees on purpose self.assertTrue(block_supports_packed_wigner(packed_conv, False)) self.assertFalse(block_supports_packed_wigner(packed_conv, True)) - # Captured inference 0 / training 1: eval rejects; a training- - # bound rotate-mix block whose flash binds for training passes. + # Captured inference 0 / training 1: eval rejects; training never + # carries the band -- the parameter-gradient consumers (the + # channel-basis backward) address the dense surface -- so even a + # training-bound rotate-mix block whose flash binds for training + # keeps the dense layout in training mode. train_conv = self._conv( _triton_rotate_mix=object(), _flash_atten_fn=object(), @@ -2170,16 +2173,13 @@ def test_gate_follows_mode_and_captured_levels(self): triton_train_level=1, ) self.assertFalse(block_supports_packed_wigner(train_conv, False)) - self.assertTrue(block_supports_packed_wigner(train_conv, True)) - # Training flash bound for inference only: the training-mode - # aggregation rotates back through the dense inverse, so the band - # is rejected even with a training-bound rotate-mix forward. + self.assertFalse(block_supports_packed_wigner(train_conv, True)) self.assertFalse( block_supports_packed_wigner( self._conv( - _triton_rotate_mix=object(), + _triton_value_path=object(), _flash_atten_fn=object(), - _flash_atten_trains=False, + _flash_atten_trains=True, triton_infer_level=2, triton_train_level=1, ), @@ -2378,6 +2378,32 @@ def _op_band(self, calc, quat): int(calc.lmax), ) + @_GPU_KERNELS + def test_fused_falls_back_to_composition_when_op_unsupported(self): + """Training keeps the plain composition (the op is eval-only).""" + import torch as torch_mod + + calc = self._make_calc(3) + calc.train() + self.assertFalse(calc.band_op_supported()) + quat = torch_mod.randn(16, 4, device="cuda") + quat = quat / quat.norm(dim=-1, keepdim=True) + fused = calc.forward_band_fused(quat) + plain = calc.forward_band(quat) + self.assertTrue(torch.equal(fused, plain)) + + def test_band_rejects_lmax_at_poly_lmin(self): + """`lmax >= poly_lmin` has no per-degree ladder below the tail.""" + from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( + WignerDCalculator, + ) + + calc = WignerDCalculator(lmax=11, dtype=torch.float32).cpu() + self.assertFalse(calc.band_supported()) + quat = torch.randn(4, 4, dtype=torch.float32, device="cpu") + with self.assertRaisesRegex(RuntimeError, "lmax < 11"): + calc.forward_band(quat) + @_GPU_KERNELS def test_matches_composition_with_grads(self): import os From f9e5fbb3ee32e51369d2c009568d7d6ad1b0545a Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sun, 25 Oct 2026 13:29:18 +0800 Subject: [PATCH 17/20] fix(pt): exclude gradient-enabled eval from the packed Wigner admission Eval mode does not freeze parameters: an eval backward against the still-trainable channel basis (e.g. validation-loss gradients) reaches the dense-only basis-grad helper of the rotate-mix backward, whose launcher reads the dense Wigner strides that do not exist on the band. The gate now rejects the layout whenever a rank>0 mixer carries a channel basis that still requires grad, in either mode; freezing the basis removes the gradient consumer and the band serves the ordinary inference backward again. The gate matrix covers both basis states, and the end-to-end class gains an eval-backward case: trainable basis -> dense cache with real parameter gradients, frozen basis -> the band returns. --- deepmd/pt/model/descriptor/sezm.py | 12 +++ source/tests/pt/model/test_descriptor_sezm.py | 74 +++++++++++++++++++ .../pt/model/test_descriptor_sezm_triton.py | 36 +++++++++ 3 files changed, 122 insertions(+) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index 345a8f4fdc..1bb6a517b4 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -162,6 +162,18 @@ def block_supports_packed_wigner(so2_conv: Any, training: bool) -> bool: # message is rotated back through the dense inverse matrix. if getattr(so2_conv, "_flash_atten_fn", None) is None: return False + # Gradient consumer: the rotate-mix backward contracts the channel-basis + # cotangent through the dense-only helper whenever the basis still + # requires grad. ``.eval()`` alone does not freeze parameters, so an + # eval-mode backward against trainable parameters (e.g. validation + # losses) must also keep the dense layout. + mixer = getattr(so2_conv, "radial_degree_mixer", None) + if ( + mixer is not None + and getattr(mixer, "rank", 0) > 0 + and getattr(getattr(mixer, "channel_basis", None), "requires_grad", False) + ): + return False # Dispatch resolves against the per-block levels captured at # construction (``active_triton_level``), not the ambient environment; # eligibility must derive from the same captured numbers. diff --git a/source/tests/pt/model/test_descriptor_sezm.py b/source/tests/pt/model/test_descriptor_sezm.py index e59861aaf5..bcd583bd0e 100644 --- a/source/tests/pt/model/test_descriptor_sezm.py +++ b/source/tests/pt/model/test_descriptor_sezm.py @@ -3207,6 +3207,13 @@ def spy(*args, **kwargs): model = DescrptSeZM(**self._kwargs()).cuda() model.eval() + # Freeze the channel basis: the production inference configuration + # (the banded layout excludes gradient-enabled consumers, so a + # trainable basis keeps the dense layout even in eval). + for block in model.blocks: + mixer = getattr(block.so2_conv, "radial_degree_mixer", None) + if mixer is not None and getattr(mixer, "channel_basis", None) is not None: + mixer.channel_basis.requires_grad_(False) with mock.patch.object(edge_cache_mod, "_build_edge_wigner", spy): with torch.no_grad(): @@ -3240,6 +3247,73 @@ def spy(*args, **kwargs): self.assertIsNotNone(d_dense_o) self.assertTrue(torch.equal(packed_out, dense_out)) + def test_eval_backward_with_trainable_basis_keeps_dense(self): + """Eval-mode parameter gradients keep the dense layout. + + ``.eval()`` does not freeze the channel-basis parameter, and an + eval backward against it would reach the dense-only basis-grad + helper, so the gate must keep the dense storage; with the basis + frozen the band serves the ordinary inference backward. + """ + import deepmd.pt.model.descriptor.sezm_nn.edge_cache as edge_cache_mod + + saved_flag = os.environ.get("DP_PACKED_D_INFER") + saved_level = os.environ.get("DP_TRITON_INFER") + os.environ["DP_PACKED_D_INFER"] = "1" + os.environ["DP_TRITON_INFER"] = "2" + + def restore(): + for key, old in ( + ("DP_PACKED_D_INFER", saved_flag), + ("DP_TRITON_INFER", saved_level), + ): + if old is None: + os.environ.pop(key, None) + else: + os.environ[key] = old + + self.addCleanup(restore) + + coord = torch.tensor( + [[[0.0, 0.0, 0.0], [0.0, 0.0, 1.5]]], dtype=torch.float32, device="cuda" + ) + atype = torch.tensor([[0, 1]], dtype=torch.long, device="cuda") + nlist = torch.tensor([[[0, 1], [1, -1]]], dtype=torch.long, device="cuda") + + records = [] + real_build = edge_cache_mod._build_edge_wigner + + def spy(*args, **kwargs): + built = real_build(*args, **kwargs) + records.append(built[3] is not None) + return built + + model = DescrptSeZM(**self._kwargs()).cuda() + model.eval() + basis_params = [] + for block in model.blocks: + mixer = getattr(block.so2_conv, "radial_degree_mixer", None) + if mixer is not None and getattr(mixer, "channel_basis", None) is not None: + basis_params.append(mixer.channel_basis) + self.assertTrue(basis_params, "the fixture must carry trainable bases") + self.assertTrue(all(param.requires_grad for param in basis_params)) + + with mock.patch.object(edge_cache_mod, "_build_edge_wigner", spy): + # Eval forward + backward against the trainable basis: the + # dense-only gradient consumer must not see the band. + out = model(coord, atype, nlist, mapping=None, comm_dict=None)[0] + out.sum().backward() + self.assertFalse(records[-1]) + self.assertIsNotNone(basis_params[0].grad) + + # Freeze the basis: the gradient consumer is gone and the + # inference band serves the ordinary backward again. + for param in basis_params: + param.requires_grad_(False) + with torch.no_grad(): + model(coord, atype, nlist, mapping=None, comm_dict=None) + self.assertTrue(records[-1]) + if __name__ == "__main__": unittest.main() diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index 6a3c10a81d..b136b8a225 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2127,6 +2127,42 @@ def test_gate_matrix(self): # The plain SO2 reference path (no Triton binding at all) reads the # dense surface. self.assertFalse(block_supports_packed_wigner(self._conv(), False)) + # An eval-mode backward against trainable channel-basis parameters + # keeps the dense layout: the basis-grad helper is dense-only, and + # ``.eval()`` does not freeze parameters. + from types import ( + SimpleNamespace, + ) + + self.assertFalse( + block_supports_packed_wigner( + self._conv( + _triton_value_path=object(), + _flash_atten_fn=object(), + radial_degree_mixer=SimpleNamespace( + rank=2, + channel_basis=SimpleNamespace(requires_grad=True), + ), + ), + False, + ) + ) + # Freezing the basis removes the gradient consumer; the band then + # serves the ordinary inference (force) backward. + self.assertTrue( + block_supports_packed_wigner( + self._conv( + _triton_value_path=object(), + _flash_atten_fn=object(), + radial_degree_mixer=SimpleNamespace( + rank=2, + channel_basis=SimpleNamespace(requires_grad=False), + ), + ), + False, + ) + ) + # A fully packed-capable selection passes in eval. self.assertTrue( block_supports_packed_wigner( From 785dc62285cdba443dfee0f3c571b49c3a527531 Mon Sep 17 00:00:00 2001 From: longjunhao Date: Sun, 1 Nov 2026 11:30:02 +0800 Subject: [PATCH 18/20] fix(sezm): optional band registration, uniform-degree gate, second-order l=1 The wigner_band registration now sits under try/except: torch < 2.6 lacks triton_op (and < 2.4 register_autograd) while every deepmd.pt import pulls this module, so a hard registration broke the whole pt backend on documented-supported releases; the eager band composition serves them, and band_op_supported reports the fused route as absent. The packed-admission gate now requires every block's degree to equal mp_init_lmax: the cache builds one band at the largest degree while each consumer kernel derives its stride and m-group offsets from its own block degree, so a decreasing l_schedule would read wrong slots out of a wider stride; pyramid schedules keep the dense surfaces. The band backward's l = 1 term keeps the saved quaternion in the graph when it can receive a second derivative and mirrors the sibling monomial backward's create_graph=is_grad_enabled(), so a Hessian through the band no longer silently drops the degree-1 contribution. Three regressions: a subprocess that removes triton_op for the module's re-execution (import survives, op stays None, public entry returns the composition values), a uniform-degree gate matrix, and an lmax == 1 second-order comparison against the composition (the whole backward is the l = 1 term there). Negative controls turn each red. --- deepmd/pt/model/descriptor/sezm.py | 8 + deepmd/pt/model/descriptor/sezm_nn/wignerd.py | 77 +++++--- .../pt/model/test_descriptor_sezm_triton.py | 168 ++++++++++++++++++ 3 files changed, 227 insertions(+), 26 deletions(-) diff --git a/deepmd/pt/model/descriptor/sezm.py b/deepmd/pt/model/descriptor/sezm.py index 1bb6a517b4..c1201b56f5 100644 --- a/deepmd/pt/model/descriptor/sezm.py +++ b/deepmd/pt/model/descriptor/sezm.py @@ -2130,6 +2130,14 @@ def _packed_wigner_ok(self) -> bool: """Every block's currently selected consumers address the band.""" if not self._blocks_have_so2_conv: return False + # The cache builds a single band at ``mp_init_lmax`` (``l_schedule[0]``, + # the largest degree), while every consumer kernel derives its + # per-edge stride and its ``m = -1 / +1`` group offsets from the + # block's own degree. A decreasing schedule would read a narrower + # band out of a wider stride, so anything but a uniform degree keeps + # the dense surfaces. + if any(int(block.so2_conv.lmax) != self.mp_init_lmax for block in self.blocks): + return False return all( block_supports_packed_wigner(block.so2_conv, self.training) for block in self.blocks diff --git a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py index 142f5c18c9..8d04287091 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/wignerd.py +++ b/deepmd/pt/model/descriptor/sezm_nn/wignerd.py @@ -723,7 +723,8 @@ def band_op_supported(self) -> bool: kernels); anything else keeps the plain composition. """ return ( - self.lmax in (1, 2, 3) + _wigner_band_op is not None + and self.lmax in (1, 2, 3) and not self.training and bool(getattr(self, "_use_triton_monomials", False)) and "exp_l2" in getattr(self, "_monomial_exponents_flat", {}) @@ -2136,24 +2137,31 @@ def _wigner_band_impl( return torch.cat(m0_rows + mm_rows + mp_rows, dim=1).contiguous() -_wigner_band_op = torch.library.triton_op("sezm_triton::wigner_band", mutates_args=())( - _wigner_band_impl -) - - -@_wigner_band_op.register_fake -def _( - quat: torch.Tensor, - l1_perm: torch.Tensor, - l1_sign: torch.Tensor, - coeff_l2: torch.Tensor, - c_l3: torch.Tensor, - exp_l2: list[int], - exp_l3: list[int], - lmax: int, -) -> torch.Tensor: - dim = (lmax + 1) ** 2 - return quat.new_empty((quat.shape[0], 3 * dim - 2)) +# Every user of ``deepmd.pt`` imports this module, so the registration must +# be optional: ``torch.library.triton_op`` arrived in torch 2.6 and +# ``register_autograd`` in 2.4, while the package documents torch >= 2.1. +# The eager band composition serves the releases that reject (or lack) the +# registration, exactly like the sibling assemble operator above. +try: + _wigner_band_op = torch.library.triton_op( + "sezm_triton::wigner_band", mutates_args=() + )(_wigner_band_impl) + + @_wigner_band_op.register_fake + def _( + quat: torch.Tensor, + l1_perm: torch.Tensor, + l1_sign: torch.Tensor, + coeff_l2: torch.Tensor, + c_l3: torch.Tensor, + exp_l2: list[int], + exp_l3: list[int], + lmax: int, + ) -> torch.Tensor: + dim = (lmax + 1) ** 2 + return quat.new_empty((quat.shape[0], 3 * dim - 2)) +except Exception: + _wigner_band_op = None def _wigner_band_setup_context( @@ -2225,7 +2233,12 @@ def _wigner_band_backward(ctx: Any, grad_band: torch.Tensor) -> tuple: g_mono = torch.matmul(g3, c_l3[2 * width : 5 * width, :]) grad_quat = grad_quat + _monomials_bwd_op(g_mono, quat, ctx.exp_l3, 6) with torch.enable_grad(): - q = quat.detach().requires_grad_(True) + # Keep the saved quat in the graph when it can receive a second + # derivative (eval backwards with position-coupled quaternions); + # otherwise differentiate against a detached leaf. create_graph + # mirrors the sibling monomial backward so the term survives a + # second differentiation instead of silently dropping out. + q = quat if quat.requires_grad else quat.detach().requires_grad_(True) if lmax >= 1: # rows (0, 1, 2) of the l=1 block come from the three groups g1 = torch.cat( @@ -2238,15 +2251,21 @@ def _wigner_band_backward(ctx: Any, grad_band: torch.Tensor) -> tuple: ).view(-1, 3, 3) rot = quaternion_to_rotation_matrix(q) block1 = rot.index_select(-2, l1_perm).index_select(-1, l1_perm) * l1_sign - grad_quat = grad_quat + torch.autograd.grad(block1, q, g1)[0] + grad_quat = ( + grad_quat + + torch.autograd.grad( + block1, q, g1, create_graph=torch.is_grad_enabled() + )[0] + ) return (grad_quat, None, None, None, None, None, None, None) -torch.library.register_autograd( - "sezm_triton::wigner_band", - _wigner_band_backward, - setup_context=_wigner_band_setup_context, -) +if _wigner_band_op is not None: + torch.library.register_autograd( + "sezm_triton::wigner_band", + _wigner_band_backward, + setup_context=_wigner_band_setup_context, + ) def wigner_band_op( @@ -2290,4 +2309,10 @@ def wigner_band_op( torch.Tensor Band tensor with shape ``(E, 3 * (lmax + 1)**2 - 2)``. """ + if _wigner_band_op is None: + # Releases without the op boundary run the same math as a plain + # composition; autograd differentiates through it natively. + return _wigner_band_impl( + quat, l1_perm, l1_sign, coeff_l2, c_l3, exp_l2, exp_l3, lmax + ) return _wigner_band_op(quat, l1_perm, l1_sign, coeff_l2, c_l3, exp_l2, exp_l3, lmax) diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index b136b8a225..bd18e78532 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2089,6 +2089,51 @@ def restore(): self.addCleanup(restore) + def test_packed_gate_requires_uniform_degree(self): + """A decreasing ``l_schedule`` must keep the dense surfaces. + + The cache builds a single band at ``mp_init_lmax`` (the largest + degree), while every consumer kernel derives its per-edge stride and + its ``m = -1 / +1`` group offsets from the block's own degree; a + smaller-degree block would read the wrong slots out of a wider + stride. The gate therefore demands a uniform degree. + """ + from types import ( + SimpleNamespace, + ) + + from deepmd.pt.model.descriptor.sezm import ( + DescrptSeZM, + ) + + self._with_levels("2", "0") + + def _block(lmax): + return SimpleNamespace( + so2_conv=self._conv( + lmax=lmax, + _triton_value_path=object(), + _flash_atten_fn=object(), + ) + ) + + def _ok(schedule, mp_lmax): + descriptor = SimpleNamespace( + _blocks_have_so2_conv=True, + training=False, + mp_init_lmax=mp_lmax, + blocks=[_block(lmax) for lmax in schedule], + ) + return DescrptSeZM._packed_wigner_ok(descriptor) + + self.assertTrue(_ok([3, 3], 3)) + self.assertFalse( + _ok([3, 2], 3), "a pyramid schedule must keep the dense surfaces" + ) + self.assertFalse( + _ok([3, 3], 2), "the band degree must match every consumer block" + ) + def test_gate_matrix(self): from deepmd.pt.model.descriptor.sezm import ( block_supports_packed_wigner, @@ -2428,6 +2473,129 @@ def test_fused_falls_back_to_composition_when_op_unsupported(self): plain = calc.forward_band(quat) self.assertTrue(torch.equal(fused, plain)) + def test_module_imports_and_falls_back_when_triton_op_is_absent(self): + """Torch < 2.6 lacks ``torch.library.triton_op``; import must survive. + + Every user of ``deepmd.pt`` imports this module, so the registration + is optional by contract. A child process removes the API for the + module's re-execution (a real 2.1-2.5 interpreter behaves the same + way), then checks the operator stays unregistered, the calculator + reports the fused route as unsupported, and the public entry returns + the eager composition's values. + """ + import pathlib + import subprocess + import sys + import textwrap + + import deepmd.pt.model.descriptor.sezm_nn.wignerd as wignerd_mod + + tree = pathlib.Path(wignerd_mod.__file__).resolve().parents[5] + child = textwrap.dedent( + """ + import pathlib + import sys + + tree = pathlib.Path(sys.argv[1]) + sys.meta_path = [ + f + for f in sys.meta_path + if "editable" + not in (type(f).__module__ + "." + type(f).__name__).lower() + and "editable" not in getattr(f, "__name__", "").lower() + ] + sys.path.insert(0, str(tree)) + import importlib + + import torch + import torch.distributed._functional_collectives # noqa: F401 + + import deepmd.pt.model.descriptor.sezm_nn.wignerd as wd + + saved = getattr(torch.library, "triton_op", None) + assert saved is not None, "control: torch >= 2.6 provides the API" + del torch.library.triton_op + wd = importlib.reload(wd) + torch.library.triton_op = saved + + assert wd._wigner_band_op is None, "fallback: op stays out" + + calc = wd.WignerDCalculator(lmax=1, dtype=torch.float32).cpu() + calc.eval() + assert not calc.band_op_supported() + + quat = torch.randn(8, 4, dtype=torch.float32, device="cpu") + quat = quat / quat.norm(dim=-1, keepdim=True) + plain = calc.forward_band(quat) + eager = wd.wigner_band_op( + quat, calc.l1_perm, calc.l1_sign_outer, None, None, [], [], 1 + ) + assert torch.allclose(plain, eager, atol=1e-6, rtol=1e-6), ( + "the public entry must keep the composition values" + ) + print("no-triton-op-ok") + """ + ) + result = subprocess.run( + [sys.executable, "-c", child, str(tree)], + capture_output=True, + text=True, + timeout=300, + ) + self.assertEqual( + result.returncode, + 0, + f"no-triton_op child failed:\n{result.stdout}\n{result.stderr}", + ) + self.assertIn("no-triton-op-ok", result.stdout) + + @_GPU_KERNELS + def test_second_order_through_band_matches_composition_l1(self): + """Lmax == 1 isolates the rotation-matrix term of the backward. + + The monomial degrees carry their own registered second order; the + l = 1 rows used to be differentiated against a detached leaf with + ``create_graph=False``, silently dropping the term from any second + derivative. With lmax == 1 the whole backward is that term, so a + mismatch here is exactly the dropped contribution. + """ + from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( + quaternion_normalize, + wigner_band_op, + ) + + calc = self._make_calc(1) + quat = torch.randn(64, 4, device="cuda") + quat = (quat / quat.norm(dim=-1, keepdim=True)).requires_grad_(True) + cotangent = torch.randn(64, 3 * 4 - 2, device="cuda") + + # lmax == 1 builds no monomial tables; the operator ignores the + # degree >= 2 arguments for this degree. + band = wigner_band_op( + quaternion_normalize(quat, eps=calc.eps), + calc.l1_perm, + calc.l1_sign_outer, + None, + None, + [], + [], + 1, + ) + first = torch.autograd.grad(band, quat, cotangent, create_graph=True)[0] + v = torch.randn_like(first) + second_op = torch.autograd.grad(first, quat, v, create_graph=True)[0] + + quat_ref = quat.detach().clone().requires_grad_(True) + band_ref = calc.forward_band(quat_ref) + first_ref = torch.autograd.grad( + band_ref, quat_ref, cotangent, create_graph=True + )[0] + second_ref = torch.autograd.grad(first_ref, quat_ref, v)[0] + + torch.testing.assert_close(first, first_ref, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(second_op, second_ref, atol=1e-4, rtol=1e-4) + self.assertGreater(float(second_op.abs().max()), 0.0) + def test_band_rejects_lmax_at_poly_lmin(self): """`lmax >= poly_lmin` has no per-degree ladder below the tail.""" from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( From 2a18e0f3612cf73e1d01297761cfb87722d06f9e Mon Sep 17 00:00:00 2001 From: longjunhao Date: Mon, 2 Nov 2026 14:13:26 +0800 Subject: [PATCH 19/20] fix(sezm): cover the packed switch wiring and its blind spots The unreachable D_packed preference in the zonal gather is removed with the invariant stated: the descriptor builds the coupling eagerly in banded mode, so a None coupling implies dense surfaces. A set-but- inert DP_PACKED_D_INFER (typically DP_CUDA_INFER selecting the CUDA Wigner calculator) now logs a one-shot warning instead of silently building dense surfaces. eval_embedding freezes model parameters around its forward, matching the frozen context the energy/force entry runs under, so the no-grad embedding path no longer trips the admission gate. Tests: forward_band_fused routes through the registered operator under the supported ladder (the production call site, previously only tested via hand-built operator arguments); the forward_with_edges model entry builds the band under the same gate as the nlist entry; and the inert flag warns exactly once. --- deepmd/pt/infer/deep_eval.py | 29 ++++-- .../pt/model/descriptor/sezm_nn/edge_cache.py | 21 +++++ .../pt/model/descriptor/sezm_nn/embedding.py | 25 +++--- source/tests/pt/model/test_descriptor_sezm.py | 67 ++++++++++++++ .../pt/model/test_descriptor_sezm_triton.py | 90 ++++++++++++++++++- 5 files changed, 206 insertions(+), 26 deletions(-) diff --git a/deepmd/pt/infer/deep_eval.py b/deepmd/pt/infer/deep_eval.py index f468379565..d46bbfebd8 100644 --- a/deepmd/pt/infer/deep_eval.py +++ b/deepmd/pt/infer/deep_eval.py @@ -1381,14 +1381,27 @@ def _eval_embedding( if charge_spin is not None else None ) - out = model.forward_embedding( - coord_input, - type_input, - box=box_input, - fparam=fparam_input, - aparam=aparam_input, - charge_spin=charge_spin_input, - ) + # The packed-Wigner admission gate (and any other inference + # contract that treats parameters as constants) matches the frozen + # context the energy/force entry runs in; hold the same contract + # here so this no-grad path does not silently fall back to a + # heavier layout. + params = tuple(model.parameters()) + saved_requires_grad = tuple(p.requires_grad for p in params) + for p in params: + p.requires_grad_(False) + try: + out = model.forward_embedding( + coord_input, + type_input, + box=box_input, + fparam=fparam_input, + aparam=aparam_input, + charge_spin=charge_spin_input, + ) + finally: + for p, flag in zip(params, saved_requires_grad, strict=True): + p.requires_grad_(flag) def cast_output(value: torch.Tensor) -> np.ndarray: value = value.detach() diff --git a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py index 93e131ab08..319d617a71 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py +++ b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py @@ -11,6 +11,8 @@ annotations, ) +import logging + import math from collections.abc import ( Callable, @@ -133,6 +135,10 @@ class EdgeFeatureCache(NamedTuple): D_packed: torch.Tensor | None = None +_warned_packed_inert = False +log = logging.getLogger(__name__) + + def cached_edge_csr( edge_cache: EdgeFeatureCache, endpoint: str, n_node: int | torch.SymInt ) -> tuple[torch.Tensor, torch.Tensor]: @@ -746,6 +752,21 @@ def _build_edge_wigner( # materialized. Reference paths that need the dense layout must not # enable the flag. return None, None, edge_quat, calc.forward_band_fused(edge_quat) + if use_packed_d_infer() and packed_wigner: + global _warned_packed_inert + if not _warned_packed_inert: + # The admission gate said yes but the resolved calculator cannot + # produce the band -- typically ``DP_CUDA_INFER`` selected the + # CUDA Wigner path (no ``forward_band``), or the ladder degree + # exceeds the band support. Dense surfaces are built instead; + # say so once instead of leaving the flag silently inert. + _warned_packed_inert = True + log.warning( + "DP_PACKED_D_INFER is set and the blocks admit the banded " + "layout, but the resolved Wigner calculator cannot produce " + "it (a CUDA inference calculator or an unsupported degree); " + "building dense Wigner surfaces." + ) D_full, Dt_full = wigner_calc(edge_quat) return D_full, Dt_full, edge_quat, None diff --git a/deepmd/pt/model/descriptor/sezm_nn/embedding.py b/deepmd/pt/model/descriptor/sezm_nn/embedding.py index cc14ba70bb..274a3d1c2b 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/embedding.py +++ b/deepmd/pt/model/descriptor/sezm_nn/embedding.py @@ -272,21 +272,16 @@ def forward( # Advanced indexing pairs one packed non-scalar row with the zonal m=0 column # from the same degree block in Dt_full. if zonal_coupling is None: - if getattr(edge_cache, "D_packed", None) is not None: - # The m = 0 band segment holds D[r0(l), d] per column d; the - # zonal entry Dt[row(l, m), col(l, 0)] equals D[r0(l), row(l, m)], - # so indexing the segment by the non-scalar rows reproduces - # the gather below without the dense surface. - zonal_coupling = getattr(edge_cache, "D_packed", None)[ - :, self.non_scalar_row_index - ] - else: - Dt_full = edge_cache.Dt_full # (E, D, D) - zonal_coupling = Dt_full[ - :, - self.non_scalar_row_index, - self.zonal_m0_col_index_for_row, - ] # (E, D-1) + # Only the dense layout reaches here: the descriptor builds the + # coupling eagerly in banded mode (where the dense surfaces do + # not exist to gather from), so a None coupling implies + # ``Dt_full`` is present. + Dt_full = edge_cache.Dt_full # (E, D, D) + zonal_coupling = Dt_full[ + :, + self.non_scalar_row_index, + self.zonal_m0_col_index_for_row, + ] # (E, D-1) # === Step 3. Broadcast radial features per row === # Each non-scalar packed row reuses the radial feature of its degree l. diff --git a/source/tests/pt/model/test_descriptor_sezm.py b/source/tests/pt/model/test_descriptor_sezm.py index bcd583bd0e..a9a5164200 100644 --- a/source/tests/pt/model/test_descriptor_sezm.py +++ b/source/tests/pt/model/test_descriptor_sezm.py @@ -3171,6 +3171,73 @@ def _kwargs(self): "radial_so2_rank": 2, } + def test_flag_on_builds_band_through_the_model_entry(self): + """The ``forward_with_edges`` entry also carries the banded storage. + + The nlist-based test drives ``DescrptSeZM.forward``; the SeZM model + itself evaluates through ``forward_with_edges`` and its + ``build_edge_cache_from_edges`` path, which must admit the band + under the same gate. + """ + import deepmd.pt.model.descriptor.sezm_nn.edge_cache as edge_cache_mod + + saved_flag = os.environ.get("DP_PACKED_D_INFER") + saved_level = os.environ.get("DP_TRITON_INFER") + os.environ["DP_PACKED_D_INFER"] = "1" + os.environ["DP_TRITON_INFER"] = "2" + + def restore(): + for key, old in ( + ("DP_PACKED_D_INFER", saved_flag), + ("DP_TRITON_INFER", saved_level), + ): + if old is None: + os.environ.pop(key, None) + else: + os.environ[key] = old + + self.addCleanup(restore) + + coord = torch.tensor( + [[[0.0, 0.0, 0.0], [0.0, 0.0, 1.5]]], dtype=torch.float32, device="cuda" + ) + atype = torch.tensor([[0, 1]], dtype=torch.long, device="cuda") + edge_index = torch.tensor([[0, 1], [1, 0]], dtype=torch.long, device="cuda") + edge_vec = torch.tensor( + [[0.0, 0.0, 1.5], [0.0, 0.0, -1.5]], dtype=torch.float32, device="cuda" + ) + edge_mask = torch.ones(2, dtype=torch.bool, device="cuda") + + records = [] + real_build = edge_cache_mod._build_edge_wigner + + def spy(*args, **kwargs): + built = real_build(*args, **kwargs) + records.append(tuple(None if t is None else tuple(t.shape) for t in built)) + return built + + model = DescrptSeZM(**self._kwargs()).cuda() + model.eval() + for block in model.blocks: + mixer = getattr(block.so2_conv, "radial_degree_mixer", None) + if mixer is not None and getattr(mixer, "channel_basis", None) is not None: + mixer.channel_basis.requires_grad_(False) + + with mock.patch.object(edge_cache_mod, "_build_edge_wigner", spy): + with torch.no_grad(): + model.forward_with_edges( + extended_coord=coord, + extended_atype=atype, + edge_index=edge_index, + edge_vec=edge_vec, + edge_mask=edge_mask, + ) + d_dense, dt_dense, _quat, d_packed = records[-1] + self.assertIsNone(d_dense) + self.assertIsNone(dt_dense) + self.assertIsNotNone(d_packed, "the with-edges entry must build the band") + self.assertEqual(d_packed, (2, 10)) + def test_flag_on_builds_band_and_matches_dense(self): import deepmd.pt.model.descriptor.sezm_nn.edge_cache as edge_cache_mod diff --git a/source/tests/pt/model/test_descriptor_sezm_triton.py b/source/tests/pt/model/test_descriptor_sezm_triton.py index bd18e78532..1220d7be56 100644 --- a/source/tests/pt/model/test_descriptor_sezm_triton.py +++ b/source/tests/pt/model/test_descriptor_sezm_triton.py @@ -2135,6 +2135,10 @@ def _ok(schedule, mp_lmax): ) def test_gate_matrix(self): + from types import ( + SimpleNamespace, + ) + from deepmd.pt.model.descriptor.sezm import ( block_supports_packed_wigner, ) @@ -2175,9 +2179,6 @@ def test_gate_matrix(self): # An eval-mode backward against trainable channel-basis parameters # keeps the dense layout: the basis-grad helper is dense-only, and # ``.eval()`` does not freeze parameters. - from types import ( - SimpleNamespace, - ) self.assertFalse( block_supports_packed_wigner( @@ -2596,6 +2597,89 @@ def test_second_order_through_band_matches_composition_l1(self): torch.testing.assert_close(second_op, second_ref, atol=1e-4, rtol=1e-4) self.assertGreater(float(second_op.abs().max()), 0.0) + @_GPU_KERNELS + def test_forward_band_fused_routes_through_the_operator(self): + """The production wiring: ``forward_band_fused`` picks the operator. + + The operator-level tests call ``wigner_band_op`` with a hand-built + argument list, and the fallback test runs in training mode; neither + exercises the call site production actually takes. Under the + supported ladder (eval, CUDA, Triton monomials at level >= 1) the + fused entry must reach the registered operator with the + calculator's own tables and match the plain composition. + """ + from unittest import ( + mock, + ) + + import deepmd.pt.model.descriptor.sezm_nn.wignerd as wd + + calc = self._make_calc(3) + quat = torch.randn(32, 4, device="cuda") + quat = quat / quat.norm(dim=-1, keepdim=True) + + calls = [] + real = wd.wigner_band_op + + def _spy(*args, **kwargs): + calls.append(args) + return real(*args, **kwargs) + + with mock.patch.dict(os.environ, {"DP_TRITON_INFER": "1"}, clear=False): + self.assertTrue(calc.band_op_supported()) + with mock.patch.object(wd, "wigner_band_op", _spy): + fused = calc.forward_band_fused(quat) + self.assertEqual(len(calls), 1, "the fused entry must call the operator") + torch.testing.assert_close(fused, calc.forward_band(quat), atol=0.0, rtol=0.0) + + def test_packed_flag_warns_once_when_the_calculator_cannot_band(self): + """A set-but-inert ``DP_PACKED_D_INFER`` says so instead of silence.""" + from unittest import ( + mock, + ) + + import deepmd.pt.model.descriptor.sezm_nn.edge_cache as ec + + edge_vec = torch.tensor( + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], device="cpu" + ) + edge_len = torch.ones(3, device="cpu") + dense_out = ( + torch.randn(3, 2, 2, device="cpu"), + torch.randn(3, 2, 2, device="cpu"), + ) + + class _DenseCalc: + # A CUDA-inference-style calculator: no forward_band at all. + def __call__(self, q): + return dense_out + + calc = _DenseCalc() + + def run(): + return ec._build_edge_wigner( + edge_vec=edge_vec, + edge_len=edge_len, + eps=1e-12, + random_gamma=False, + wigner_calc=calc, + build_full=True, + packed_wigner=True, + ) + + with ( + mock.patch.dict(os.environ, {"DP_PACKED_D_INFER": "1"}, clear=False), + mock.patch.object(ec, "_warned_packed_inert", False), + ): + with self.assertLogs( + "deepmd.pt.model.descriptor.sezm_nn.edge_cache", level="WARNING" + ) as logged: + run() + # second call stays silent: the warning fires once + run() + self.assertEqual(len(logged.records), 1) + self.assertIn("DP_PACKED_D_INFER", logged.records[0].getMessage()) + def test_band_rejects_lmax_at_poly_lmin(self): """`lmax >= poly_lmin` has no per-degree ladder below the tail.""" from deepmd.pt.model.descriptor.sezm_nn.wignerd import ( From 0b895d29b065ad0aef6df9a788a98f1109105c9f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:14:47 +0000 Subject: [PATCH 20/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- deepmd/pt/model/descriptor/sezm_nn/edge_cache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py index 319d617a71..71c99941d6 100644 --- a/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py +++ b/deepmd/pt/model/descriptor/sezm_nn/edge_cache.py @@ -12,7 +12,6 @@ ) import logging - import math from collections.abc import ( Callable,