@@ -99,6 +99,37 @@ def _build_frame_degree_index(
9999 raise ValueError ("`coefficient_layout` must be either 'packed' or 'm_major'" )
100100
101101
102+ def _degree_batched_matmul (xp : Any , coeff : Any , weight : Any ) -> Any :
103+ """Contract ``einsum("ndfi,dio->ndfo")`` batched over the degree axis.
104+
105+ Parameters
106+ ----------
107+ xp : Any
108+ The array namespace of ``coeff``.
109+ coeff : Array
110+ Coefficients with shape ``(N, D, F, i)``.
111+ weight : Array
112+ Per-degree weights with shape ``(D, i, o)``.
113+
114+ Returns
115+ -------
116+ Array
117+ Contracted coefficients with shape ``(N, D, F, o)``.
118+
119+ Notes
120+ -----
121+ Batching over the ``(D, F)`` axes, not over ``N``: expanding ``weight``
122+ across ``F`` costs ``D*F*i*o`` elements, whereas batching over ``N``
123+ (or collapsing ``N*F``, which needs a materialized permuted copy of
124+ ``coeff``) touches ``N*D*F*i`` elements — a factor ``N/o`` more. No
125+ reshape is involved, so an empty ``N`` batch (empty graph/edge set, or
126+ a distributed rank owning no nodes) flows through naturally.
127+ """
128+ coeff_df = xp .permute_dims (coeff , (1 , 2 , 0 , 3 )) # (D, F, N, i)
129+ out = xp .matmul (coeff_df , weight [:, None , :, :]) # (D, F, N, o)
130+ return xp .permute_dims (out , (2 , 0 , 1 , 3 )) # (N, D, F, o)
131+
132+
102133def _project_frames (coeff : Any , proj : ChannelLinear , n_frames : int ) -> Any :
103134 """
104135 Apply a channel-only linear map to each Wigner-D frame independently.
@@ -493,9 +524,8 @@ def call(self, coeff: Any) -> Any:
493524 weight = xp_asarray_nodetach (xp , self .weight [...], device = device )
494525 degree_index = xp_asarray_nodetach (xp , self .degree_index , device = device )
495526 weight = xp .take (weight , degree_index , axis = 0 )
496- # einsum "ndfi,dio->ndfo" as a broadcast batched matmul:
497- # (N, D, F, i) @ (1, D, i, o) -> (N, D, F, o)
498- return xp .matmul (coeff , weight [None , ...])
527+ # Batched over the (D, F) axes, never over N -- see the helper's note.
528+ return _degree_batched_matmul (xp , coeff , weight )
499529
500530 def serialize (self ) -> dict [str , Any ]:
501531 """Serialize the FrameContract to a dict."""
@@ -575,9 +605,8 @@ def call(self, coeff: Any) -> Any:
575605 weight = xp_asarray_nodetach (xp , self .weight [...], device = device )
576606 degree_index = xp_asarray_nodetach (xp , self .degree_index , device = device )
577607 weight = xp .take (weight , degree_index , axis = 0 )
578- # einsum "ndfi,dio->ndfo" as a broadcast batched matmul:
579- # (N, D, F, i) @ (1, D, i, o) -> (N, D, F, o)
580- return xp .matmul (coeff , weight [None , ...])
608+ # Batched over the (D, F) axes, never over N -- see the helper's note.
609+ return _degree_batched_matmul (xp , coeff , weight )
581610
582611 def serialize (self ) -> dict [str , Any ]:
583612 """Serialize the FrameExpand to a dict."""
0 commit comments