flopscope 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchmarks/__init__.py +1 -0
- benchmarks/__main__.py +6 -0
- benchmarks/_baseline.py +171 -0
- benchmarks/_bitwise.py +231 -0
- benchmarks/_complex.py +176 -0
- benchmarks/_contractions.py +291 -0
- benchmarks/_fft.py +198 -0
- benchmarks/_impl_urls.py +139 -0
- benchmarks/_linalg.py +197 -0
- benchmarks/_linalg_delegates.py +407 -0
- benchmarks/_metadata.py +141 -0
- benchmarks/_misc.py +653 -0
- benchmarks/_perf.py +321 -0
- benchmarks/_perm_group_calibration.py +175 -0
- benchmarks/_pointwise.py +372 -0
- benchmarks/_polynomial.py +193 -0
- benchmarks/_random.py +209 -0
- benchmarks/_reductions.py +136 -0
- benchmarks/_sorting.py +289 -0
- benchmarks/_stats.py +137 -0
- benchmarks/_window.py +92 -0
- benchmarks/accumulation/__init__.py +0 -0
- benchmarks/accumulation/bench_cost_compute.py +138 -0
- benchmarks/dashboard.py +312 -0
- benchmarks/runner.py +636 -0
- flopscope/__init__.py +273 -0
- flopscope/_accumulation/__init__.py +13 -0
- flopscope/_accumulation/_bipartite.py +121 -0
- flopscope/_accumulation/_burnside.py +51 -0
- flopscope/_accumulation/_cache.py +146 -0
- flopscope/_accumulation/_components.py +153 -0
- flopscope/_accumulation/_cost.py +1414 -0
- flopscope/_accumulation/_cost_descriptions.py +63 -0
- flopscope/_accumulation/_detection.py +318 -0
- flopscope/_accumulation/_ladder.py +191 -0
- flopscope/_accumulation/_output_orbit.py +104 -0
- flopscope/_accumulation/_partition.py +290 -0
- flopscope/_accumulation/_path_info.py +211 -0
- flopscope/_accumulation/_public.py +169 -0
- flopscope/_accumulation/_reduction.py +310 -0
- flopscope/_accumulation/_regimes.py +303 -0
- flopscope/_accumulation/_shape.py +33 -0
- flopscope/_accumulation/_wreath.py +209 -0
- flopscope/_budget.py +1027 -0
- flopscope/_config.py +118 -0
- flopscope/_counting_ops.py +451 -0
- flopscope/_display.py +478 -0
- flopscope/_docstrings.py +59 -0
- flopscope/_dtypes.py +20 -0
- flopscope/_einsum.py +717 -0
- flopscope/_errstate.py +25 -0
- flopscope/_flops.py +282 -0
- flopscope/_free_ops.py +2654 -0
- flopscope/_ndarray.py +1126 -0
- flopscope/_opt_einsum/LICENSE +21 -0
- flopscope/_opt_einsum/NOTICE +59 -0
- flopscope/_opt_einsum/__init__.py +209 -0
- flopscope/_opt_einsum/_contract.py +1478 -0
- flopscope/_opt_einsum/_helpers.py +164 -0
- flopscope/_opt_einsum/_hsluv.py +273 -0
- flopscope/_opt_einsum/_path_random.py +462 -0
- flopscope/_opt_einsum/_paths.py +1653 -0
- flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
- flopscope/_opt_einsum/_symmetry.py +140 -0
- flopscope/_opt_einsum/_typing.py +37 -0
- flopscope/_perm_group.py +717 -0
- flopscope/_pointwise.py +2522 -0
- flopscope/_polynomial.py +278 -0
- flopscope/_registry.py +3216 -0
- flopscope/_sorting_ops.py +571 -0
- flopscope/_symmetric.py +812 -0
- flopscope/_symmetry_transport.py +510 -0
- flopscope/_symmetry_utils.py +669 -0
- flopscope/_type_info.py +12 -0
- flopscope/_unwrap.py +70 -0
- flopscope/_validation.py +83 -0
- flopscope/_version_check.py +46 -0
- flopscope/_weights.py +195 -0
- flopscope/_window.py +177 -0
- flopscope/accounting.py +565 -0
- flopscope/data/default_weights.json +462 -0
- flopscope/data/weights.csv +509 -0
- flopscope/errors.py +197 -0
- flopscope/numpy/__init__.py +878 -0
- flopscope/numpy/fft/__init__.py +55 -0
- flopscope/numpy/fft/_free.py +51 -0
- flopscope/numpy/fft/_transforms.py +695 -0
- flopscope/numpy/linalg/__init__.py +105 -0
- flopscope/numpy/linalg/_aliases.py +126 -0
- flopscope/numpy/linalg/_compound.py +161 -0
- flopscope/numpy/linalg/_decompositions.py +353 -0
- flopscope/numpy/linalg/_properties.py +533 -0
- flopscope/numpy/linalg/_solvers.py +444 -0
- flopscope/numpy/linalg/_svd.py +122 -0
- flopscope/numpy/random/__init__.py +684 -0
- flopscope/numpy/random/_cost_formulas.py +115 -0
- flopscope/numpy/random/_counted_classes.py +241 -0
- flopscope/numpy/testing/__init__.py +13 -0
- flopscope/numpy/typing/__init__.py +30 -0
- flopscope/py.typed +0 -0
- flopscope/stats/__init__.py +84 -0
- flopscope/stats/_base.py +77 -0
- flopscope/stats/_cauchy.py +146 -0
- flopscope/stats/_erf.py +190 -0
- flopscope/stats/_expon.py +146 -0
- flopscope/stats/_laplace.py +150 -0
- flopscope/stats/_logistic.py +148 -0
- flopscope/stats/_lognorm.py +160 -0
- flopscope/stats/_ndtri.py +133 -0
- flopscope/stats/_norm.py +149 -0
- flopscope/stats/_truncnorm.py +186 -0
- flopscope/stats/_uniform.py +141 -0
- flopscope-0.2.0.dist-info/METADATA +23 -0
- flopscope-0.2.0.dist-info/RECORD +115 -0
- flopscope-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Subset-keyed subgraph symmetry detection for einsum intermediates.
|
|
2
|
+
|
|
3
|
+
One oracle per contract_path call. Given the original operand list,
|
|
4
|
+
subscript parts, per-operand declared symmetries, and output subscript,
|
|
5
|
+
builds a bipartite graph once and exposes ``.sym(subset)`` which returns
|
|
6
|
+
a ``SubsetSymmetry`` with ``.output`` (V-side) and ``.inner`` (W-side)
|
|
7
|
+
symmetries, computed lazily on first access and cached.
|
|
8
|
+
|
|
9
|
+
Each axis of each operand gets its own U-vertex in the bipartite graph
|
|
10
|
+
(no axis merging). The σ-loop iterates over generators from three sources:
|
|
11
|
+
(A) per-operand internal symmetry generators, (B) identical-operand swap
|
|
12
|
+
generators, and (C) coordinated axis relabeling for identical operands
|
|
13
|
+
with the same subscript (W-side only). Dimino's algorithm builds the full
|
|
14
|
+
row-permutation group from these generators, and π is derived for each
|
|
15
|
+
group element via column-fingerprint hash lookup.
|
|
16
|
+
|
|
17
|
+
See docs/explanation/subgraph-symmetry.md for the algorithm walkthrough.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from flopscope._perm_group import SymmetryGroup
|
|
26
|
+
from flopscope._perm_group import _Permutation as Perm
|
|
27
|
+
|
|
28
|
+
from ._symmetry import SubsetSymmetry
|
|
29
|
+
|
|
30
|
+
_MISSING = object()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _derive_pi_canonical(
|
|
34
|
+
sigma_col_of: dict[str, tuple[int, ...]],
|
|
35
|
+
fp_to_labels: dict[tuple[int, ...], set[str]],
|
|
36
|
+
v_labels: frozenset[str],
|
|
37
|
+
w_labels: frozenset[str],
|
|
38
|
+
) -> dict[str, str] | None:
|
|
39
|
+
"""Build π by canonical hash lookup. Returns None on failure.
|
|
40
|
+
|
|
41
|
+
For each label ℓ, looks up σ(M)'s column fingerprint in fp_to_labels
|
|
42
|
+
and picks the lex-first unused candidate. Validates that π is a
|
|
43
|
+
bijection and preserves the V/W partition.
|
|
44
|
+
"""
|
|
45
|
+
pi: dict[str, str] = {}
|
|
46
|
+
used: set[str] = set()
|
|
47
|
+
all_labels = v_labels | w_labels
|
|
48
|
+
|
|
49
|
+
for label in sorted(all_labels):
|
|
50
|
+
fp = sigma_col_of[label]
|
|
51
|
+
candidates = fp_to_labels.get(fp)
|
|
52
|
+
if not candidates:
|
|
53
|
+
return None
|
|
54
|
+
pick = None
|
|
55
|
+
for c in sorted(candidates):
|
|
56
|
+
if c not in used:
|
|
57
|
+
pick = c
|
|
58
|
+
break
|
|
59
|
+
if pick is None:
|
|
60
|
+
return None
|
|
61
|
+
pi[label] = pick
|
|
62
|
+
used.add(pick)
|
|
63
|
+
|
|
64
|
+
# Validate V→V and W→W.
|
|
65
|
+
for lbl, target in pi.items():
|
|
66
|
+
if lbl in v_labels and target not in v_labels:
|
|
67
|
+
return None
|
|
68
|
+
if lbl in w_labels and target not in w_labels:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
return pi
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _collect_pi_permutations(
|
|
75
|
+
graph: EinsumBipartite,
|
|
76
|
+
sub: _Subgraph,
|
|
77
|
+
row_order: tuple[int, ...],
|
|
78
|
+
col_of: dict[str, tuple[int, ...]],
|
|
79
|
+
fp_to_labels: dict[tuple[int, ...], set[str]],
|
|
80
|
+
) -> tuple[list[Perm], list[Perm], list[Perm]]:
|
|
81
|
+
"""Collect V, W, and joint permutation generators via the expanded σ-loop.
|
|
82
|
+
|
|
83
|
+
Generators come from two sources:
|
|
84
|
+
|
|
85
|
+
Source A — per-operand internal symmetry generators. For each operand
|
|
86
|
+
in the subset that has declared groups, each generator's array form
|
|
87
|
+
(mapped through ``group.axes``) permutes U-vertex positions within
|
|
88
|
+
that operand's block.
|
|
89
|
+
|
|
90
|
+
Source B — identical-operand swap generators. For each pair of
|
|
91
|
+
adjacent operands in an identical-operand group, an adjacent
|
|
92
|
+
transposition that swaps their entire U-vertex blocks.
|
|
93
|
+
|
|
94
|
+
For each generator, the induced column permutation π is derived via
|
|
95
|
+
``_derive_pi_canonical``.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
v_perms : list[Perm]
|
|
100
|
+
Non-identity permutations on V labels (output / free side).
|
|
101
|
+
w_perms : list[Perm]
|
|
102
|
+
Non-identity permutations on W labels (inner / summed side).
|
|
103
|
+
joint_perms : list[Perm]
|
|
104
|
+
Non-identity permutations on sorted(V ∪ W) labels (joint group).
|
|
105
|
+
"""
|
|
106
|
+
v_perms: list[Perm] = []
|
|
107
|
+
w_perms: list[Perm] = []
|
|
108
|
+
joint_perms: list[Perm] = []
|
|
109
|
+
all_labels = sub.v_labels | sub.w_labels
|
|
110
|
+
v_sorted = tuple(sorted(sub.v_labels))
|
|
111
|
+
w_sorted = tuple(sorted(sub.w_labels))
|
|
112
|
+
all_sorted = tuple(sorted(all_labels))
|
|
113
|
+
v_idx = {lbl: i for i, lbl in enumerate(v_sorted)}
|
|
114
|
+
w_idx = {lbl: i for i, lbl in enumerate(w_sorted)}
|
|
115
|
+
all_idx = {lbl: i for i, lbl in enumerate(all_sorted)}
|
|
116
|
+
|
|
117
|
+
n_rows = len(row_order)
|
|
118
|
+
identity_row = tuple(range(n_rows))
|
|
119
|
+
|
|
120
|
+
# Map: operand_idx -> list of positions in row_order belonging to it.
|
|
121
|
+
op_to_u_indices: dict[int, list[int]] = {}
|
|
122
|
+
for pos, u_idx in enumerate(row_order):
|
|
123
|
+
op = graph.u_operand[u_idx]
|
|
124
|
+
op_to_u_indices.setdefault(op, []).append(pos)
|
|
125
|
+
|
|
126
|
+
# Collect row-permutation generators, then derive π for each.
|
|
127
|
+
row_perm_generators: list[tuple[int, ...]] = []
|
|
128
|
+
|
|
129
|
+
# --- Source A: per-operand internal symmetry generators ---
|
|
130
|
+
for op_idx in sorted({graph.u_operand[u] for u in row_order}):
|
|
131
|
+
groups = graph.per_op_groups[op_idx]
|
|
132
|
+
if groups is None:
|
|
133
|
+
continue
|
|
134
|
+
positions = op_to_u_indices.get(op_idx, [])
|
|
135
|
+
if not positions:
|
|
136
|
+
continue
|
|
137
|
+
for group in groups:
|
|
138
|
+
# Map group axis indices to positions within this operand's
|
|
139
|
+
# block in row_order. group.axes[i] is the tensor axis index
|
|
140
|
+
# that group position i acts on. Since we no longer merge,
|
|
141
|
+
# each axis position in the subscript maps 1:1 to a U-vertex.
|
|
142
|
+
# The operand's subscript gives us the axis->position mapping.
|
|
143
|
+
subscript = graph.operand_subscripts[op_idx]
|
|
144
|
+
# group._labels are the subscript chars the group acts on.
|
|
145
|
+
# For each generator, we need to map its array_form through
|
|
146
|
+
# the axis indirection to produce a row permutation.
|
|
147
|
+
# Build: group_pos -> row_order position
|
|
148
|
+
# group._labels[g_pos] is the char at group position g_pos.
|
|
149
|
+
# We need to find which axis position in the subscript that
|
|
150
|
+
# char corresponds to, respecting group.axes.
|
|
151
|
+
if group.axes is not None:
|
|
152
|
+
# group.axes[g_pos] = tensor axis index
|
|
153
|
+
gpos_to_rowpos = {}
|
|
154
|
+
for g_pos in range(group.degree):
|
|
155
|
+
axis_idx = group.axes[g_pos]
|
|
156
|
+
if axis_idx < len(positions):
|
|
157
|
+
gpos_to_rowpos[g_pos] = positions[axis_idx]
|
|
158
|
+
elif group._labels is not None:
|
|
159
|
+
# No axes, but _labels maps group positions to subscript
|
|
160
|
+
# chars. Find the subscript position of each label.
|
|
161
|
+
gpos_to_rowpos = {}
|
|
162
|
+
for g_pos in range(group.degree):
|
|
163
|
+
lbl = group._labels[g_pos]
|
|
164
|
+
# Find position of this label in the subscript
|
|
165
|
+
sub_pos = subscript.find(lbl)
|
|
166
|
+
if sub_pos >= 0 and sub_pos < len(positions):
|
|
167
|
+
gpos_to_rowpos[g_pos] = positions[sub_pos]
|
|
168
|
+
else:
|
|
169
|
+
# Default: group position i acts on operand axis i
|
|
170
|
+
gpos_to_rowpos = {}
|
|
171
|
+
for g_pos in range(group.degree):
|
|
172
|
+
if g_pos < len(positions):
|
|
173
|
+
gpos_to_rowpos[g_pos] = positions[g_pos]
|
|
174
|
+
|
|
175
|
+
for gen in group.generators:
|
|
176
|
+
arr = gen.array_form
|
|
177
|
+
# Build row permutation: start from identity, then
|
|
178
|
+
# permute the positions that this generator acts on.
|
|
179
|
+
row_perm = list(identity_row)
|
|
180
|
+
is_identity = True
|
|
181
|
+
for g_pos in range(len(arr)):
|
|
182
|
+
if arr[g_pos] != g_pos:
|
|
183
|
+
src = gpos_to_rowpos.get(g_pos)
|
|
184
|
+
dst = gpos_to_rowpos.get(arr[g_pos])
|
|
185
|
+
if src is not None and dst is not None:
|
|
186
|
+
row_perm[src] = identity_row[dst]
|
|
187
|
+
is_identity = False
|
|
188
|
+
if not is_identity:
|
|
189
|
+
row_perm_generators.append(tuple(row_perm))
|
|
190
|
+
|
|
191
|
+
# --- Source B: identical-operand swap generators ---
|
|
192
|
+
for group in sub.id_groups:
|
|
193
|
+
group_sorted = sorted(group)
|
|
194
|
+
for idx in range(len(group_sorted) - 1):
|
|
195
|
+
op_a = group_sorted[idx]
|
|
196
|
+
op_b = group_sorted[idx + 1]
|
|
197
|
+
pos_a = op_to_u_indices.get(op_a, [])
|
|
198
|
+
pos_b = op_to_u_indices.get(op_b, [])
|
|
199
|
+
if len(pos_a) != len(pos_b):
|
|
200
|
+
continue # block sizes must match
|
|
201
|
+
row_perm = list(identity_row)
|
|
202
|
+
for pa, pb in zip(pos_a, pos_b, strict=False):
|
|
203
|
+
row_perm[pa] = identity_row[pb]
|
|
204
|
+
row_perm[pb] = identity_row[pa]
|
|
205
|
+
row_perm_generators.append(tuple(row_perm))
|
|
206
|
+
|
|
207
|
+
# --- Source C: coordinated axis relabeling for identical operands ---
|
|
208
|
+
# When identical operands share the same subscript pattern, permuting
|
|
209
|
+
# axes uniformly across all copies is equivalent to relabeling dummy
|
|
210
|
+
# indices. Only valid when BOTH labels involved in the swap are
|
|
211
|
+
# summed (W-side) — relabeling free (V-side) labels changes the output.
|
|
212
|
+
# Generate adjacent transpositions on W-only axis pairs, applied to
|
|
213
|
+
# every copy simultaneously.
|
|
214
|
+
for group in sub.id_groups:
|
|
215
|
+
group_sorted = sorted(group)
|
|
216
|
+
# Check all operands in this group have the same subscript
|
|
217
|
+
subs_list = [graph.operand_subscripts[op] for op in group_sorted]
|
|
218
|
+
if len(set(subs_list)) != 1:
|
|
219
|
+
continue # different subscripts — can't do coordinated relabeling
|
|
220
|
+
subscript = subs_list[0]
|
|
221
|
+
rank = len(subscript)
|
|
222
|
+
if rank < 2:
|
|
223
|
+
continue
|
|
224
|
+
# Find which axis positions have W-only (summed) labels
|
|
225
|
+
w_axes = [ax for ax in range(rank) if subscript[ax] in sub.w_labels]
|
|
226
|
+
if len(w_axes) < 2:
|
|
227
|
+
continue
|
|
228
|
+
# Generate adjacent transpositions on W-only axes
|
|
229
|
+
for idx in range(len(w_axes) - 1):
|
|
230
|
+
ax_a = w_axes[idx]
|
|
231
|
+
ax_b = w_axes[idx + 1]
|
|
232
|
+
row_perm = list(identity_row)
|
|
233
|
+
is_identity = True
|
|
234
|
+
for op_idx in group_sorted:
|
|
235
|
+
positions = op_to_u_indices.get(op_idx, [])
|
|
236
|
+
if ax_a >= len(positions) or ax_b >= len(positions):
|
|
237
|
+
continue
|
|
238
|
+
pa, pb = positions[ax_a], positions[ax_b]
|
|
239
|
+
row_perm[pa] = identity_row[pb]
|
|
240
|
+
row_perm[pb] = identity_row[pa]
|
|
241
|
+
is_identity = False
|
|
242
|
+
if not is_identity:
|
|
243
|
+
row_perm_generators.append(tuple(row_perm))
|
|
244
|
+
|
|
245
|
+
# --- Build a group on row positions and enumerate all elements ---
|
|
246
|
+
if not row_perm_generators:
|
|
247
|
+
return v_perms, w_perms, joint_perms
|
|
248
|
+
|
|
249
|
+
row_gens = [Perm(list(g)) for g in row_perm_generators]
|
|
250
|
+
row_group = SymmetryGroup(*row_gens)
|
|
251
|
+
|
|
252
|
+
for sigma_perm in row_group.elements():
|
|
253
|
+
sigma_row_perm = sigma_perm.array_form
|
|
254
|
+
# Skip identity σ — it always gives π = identity.
|
|
255
|
+
if all(sigma_row_perm[k] == k for k in range(n_rows)):
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# Compute σ(M)'s column fingerprints.
|
|
259
|
+
sigma_col_of: dict[str, tuple[int, ...]] = {}
|
|
260
|
+
for label in all_labels:
|
|
261
|
+
sigma_col_of[label] = tuple(
|
|
262
|
+
graph.incidence[row_order[sigma_row_perm[k]]].get(label, 0)
|
|
263
|
+
for k in range(n_rows)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Derive π.
|
|
267
|
+
pi = _derive_pi_canonical(
|
|
268
|
+
sigma_col_of, fp_to_labels, sub.v_labels, sub.w_labels
|
|
269
|
+
)
|
|
270
|
+
if pi is None:
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# Restrict π to V labels — emit Perm if non-identity.
|
|
274
|
+
if sub.v_labels and any(pi.get(lbl, lbl) != lbl for lbl in sub.v_labels):
|
|
275
|
+
arr = [v_idx[pi.get(lbl, lbl)] for lbl in v_sorted]
|
|
276
|
+
v_perms.append(Perm(arr))
|
|
277
|
+
|
|
278
|
+
# Restrict π to W labels — emit Perm if non-identity.
|
|
279
|
+
if sub.w_labels and any(pi.get(lbl, lbl) != lbl for lbl in sub.w_labels):
|
|
280
|
+
arr = [w_idx[pi.get(lbl, lbl)] for lbl in w_sorted]
|
|
281
|
+
w_perms.append(Perm(arr))
|
|
282
|
+
|
|
283
|
+
# Joint permutation on sorted(V ∪ W) — emit Perm if non-identity.
|
|
284
|
+
if any(pi.get(lbl, lbl) != lbl for lbl in all_sorted):
|
|
285
|
+
arr_joint = [all_idx[pi.get(lbl, lbl)] for lbl in all_sorted]
|
|
286
|
+
joint_perms.append(Perm(arr_joint))
|
|
287
|
+
|
|
288
|
+
return v_perms, w_perms, joint_perms
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class EinsumBipartite:
|
|
293
|
+
"""Bipartite graph representation of an einsum expression.
|
|
294
|
+
|
|
295
|
+
Left vertices U: one per (operand_idx, axis_position). Each axis of
|
|
296
|
+
each operand gets its own U-vertex (no merging). For a dense operand
|
|
297
|
+
with subscript "ai" we get two U vertices — one for axis 0 ({a}),
|
|
298
|
+
one for axis 1 ({i}). A fully symmetric operand T with subscript
|
|
299
|
+
"ij" also gets two U vertices — one per axis.
|
|
300
|
+
|
|
301
|
+
Right vertices are labels, partitioned at the top level into
|
|
302
|
+
free_labels (V, the final output) and summed_labels (W, contracted
|
|
303
|
+
at the top level). Subset induction may reclassify labels from W
|
|
304
|
+
to V when they cross the cut.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
# Parallel tuples over U vertices:
|
|
308
|
+
u_vertices: tuple[tuple[int, int], ...] # (operand_idx, class_id)
|
|
309
|
+
u_labels: tuple[frozenset[str], ...] # which labels this class contains
|
|
310
|
+
u_operand: tuple[int, ...] # operand index
|
|
311
|
+
incidence: tuple[dict[str, int], ...] # {label -> multiplicity}
|
|
312
|
+
|
|
313
|
+
# Right vertices, top-level partition:
|
|
314
|
+
free_labels: frozenset[str] # V at the top level
|
|
315
|
+
summed_labels: frozenset[str] # W at the top level
|
|
316
|
+
|
|
317
|
+
# Python-identity groups: partition of [0..num_operands),
|
|
318
|
+
# non-singleton blocks enumerate identical operands.
|
|
319
|
+
identical_operand_groups: tuple[tuple[int, ...], ...]
|
|
320
|
+
|
|
321
|
+
# Per-operand label set, needed for subset induction to compute
|
|
322
|
+
# crossing labels efficiently without re-scanning incidence.
|
|
323
|
+
operand_labels: tuple[frozenset[str], ...]
|
|
324
|
+
|
|
325
|
+
# Per-operand subscript string, needed by the block path helper
|
|
326
|
+
# to reconstruct free-to-one-operand label sets in positional order.
|
|
327
|
+
operand_subscripts: tuple[str, ...] # parallel to operand_labels
|
|
328
|
+
|
|
329
|
+
# Declared symmetry groups per operand, preserved from construction
|
|
330
|
+
# so the fingerprint fast path can use exact declared groups instead
|
|
331
|
+
# of always promoting to S_k.
|
|
332
|
+
per_op_groups: tuple[tuple[SymmetryGroup, ...] | None, ...]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _build_bipartite(
|
|
336
|
+
operands: list[Any],
|
|
337
|
+
subscript_parts: list[str],
|
|
338
|
+
per_op_groups: list[list[SymmetryGroup] | None],
|
|
339
|
+
output_chars: str,
|
|
340
|
+
) -> EinsumBipartite:
|
|
341
|
+
"""Construct the bipartite graph for an einsum expression.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
operands : list
|
|
346
|
+
Original operand objects; Python identity is used to detect
|
|
347
|
+
repeated operands.
|
|
348
|
+
subscript_parts : list[str]
|
|
349
|
+
Per-operand subscript strings (e.g., ["ij", "jk"]).
|
|
350
|
+
per_op_groups : list
|
|
351
|
+
Declared symmetry for each operand, as a list of
|
|
352
|
+
SymmetryGroup objects (with ``_labels`` set), or None.
|
|
353
|
+
output_chars : str
|
|
354
|
+
Output subscript string.
|
|
355
|
+
"""
|
|
356
|
+
u_vertices: list[tuple[int, int]] = []
|
|
357
|
+
u_labels: list[frozenset[str]] = []
|
|
358
|
+
u_operand: list[int] = []
|
|
359
|
+
incidence: list[dict[str, int]] = []
|
|
360
|
+
operand_labels: list[frozenset[str]] = []
|
|
361
|
+
|
|
362
|
+
for op_idx, sub in enumerate(subscript_parts):
|
|
363
|
+
operand_labels.append(frozenset(sub))
|
|
364
|
+
per_op_groups[op_idx]
|
|
365
|
+
|
|
366
|
+
# Each axis gets its own U-vertex (no merging). The σ-loop handles
|
|
367
|
+
# symmetry detection via per-operand generators instead.
|
|
368
|
+
class_of_position: dict[int, int] = {k: k for k in range(len(sub))}
|
|
369
|
+
|
|
370
|
+
# Build one U vertex per axis, with incidence = label multiplicity.
|
|
371
|
+
num_classes = len(sub)
|
|
372
|
+
class_incidence: list[dict[str, int]] = [{} for _ in range(num_classes)]
|
|
373
|
+
class_labels: list[set[str]] = [set() for _ in range(num_classes)]
|
|
374
|
+
for k, c in enumerate(sub):
|
|
375
|
+
cls = class_of_position[k]
|
|
376
|
+
class_incidence[cls][c] = class_incidence[cls].get(c, 0) + 1
|
|
377
|
+
class_labels[cls].add(c)
|
|
378
|
+
|
|
379
|
+
for cls in range(num_classes):
|
|
380
|
+
u_vertices.append((op_idx, cls))
|
|
381
|
+
u_labels.append(frozenset(class_labels[cls]))
|
|
382
|
+
u_operand.append(op_idx)
|
|
383
|
+
incidence.append(class_incidence[cls])
|
|
384
|
+
|
|
385
|
+
# Partition labels into free (V) vs summed (W) at the top level
|
|
386
|
+
output_set = frozenset(output_chars)
|
|
387
|
+
all_labels = frozenset().union(*operand_labels) if operand_labels else frozenset()
|
|
388
|
+
free_labels = all_labels & output_set
|
|
389
|
+
summed_labels = all_labels - output_set
|
|
390
|
+
|
|
391
|
+
# Identical-operand groups via Python id
|
|
392
|
+
id_to_positions: dict[int, list[int]] = {}
|
|
393
|
+
for op_idx, op in enumerate(operands):
|
|
394
|
+
id_to_positions.setdefault(id(op), []).append(op_idx)
|
|
395
|
+
identical_operand_groups = tuple(
|
|
396
|
+
tuple(positions)
|
|
397
|
+
for positions in id_to_positions.values()
|
|
398
|
+
if len(positions) >= 2
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return EinsumBipartite(
|
|
402
|
+
u_vertices=tuple(u_vertices),
|
|
403
|
+
u_labels=tuple(u_labels),
|
|
404
|
+
u_operand=tuple(u_operand),
|
|
405
|
+
incidence=tuple(incidence),
|
|
406
|
+
free_labels=free_labels,
|
|
407
|
+
summed_labels=summed_labels,
|
|
408
|
+
identical_operand_groups=identical_operand_groups,
|
|
409
|
+
operand_labels=tuple(operand_labels),
|
|
410
|
+
operand_subscripts=tuple(subscript_parts),
|
|
411
|
+
per_op_groups=tuple(
|
|
412
|
+
tuple(gs) if gs is not None else None for gs in per_op_groups
|
|
413
|
+
),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class _Subgraph:
|
|
419
|
+
"""Induced subgraph of an EinsumBipartite on a subset of operands.
|
|
420
|
+
|
|
421
|
+
u_local: list of U-vertex indices (indices into graph.u_vertices) that
|
|
422
|
+
belong to operands in the subset.
|
|
423
|
+
v_labels: labels that are free at this step (output or crossing the cut).
|
|
424
|
+
w_labels: labels that are summed entirely within the subset.
|
|
425
|
+
id_groups: identical-operand groups restricted to the subset.
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
u_local: tuple[int, ...]
|
|
429
|
+
v_labels: frozenset[str]
|
|
430
|
+
w_labels: frozenset[str]
|
|
431
|
+
id_groups: tuple[tuple[int, ...], ...]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _induce_subgraph(graph: EinsumBipartite, subset: frozenset[int]) -> _Subgraph:
|
|
435
|
+
u_local = tuple(
|
|
436
|
+
idx for idx, op_idx in enumerate(graph.u_operand) if op_idx in subset
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
labels_in_subset: set[str] = set()
|
|
440
|
+
for idx in u_local:
|
|
441
|
+
labels_in_subset.update(graph.incidence[idx].keys())
|
|
442
|
+
|
|
443
|
+
# Labels appearing in operands outside the subset (crossing the cut).
|
|
444
|
+
outside_labels: set[str] = set()
|
|
445
|
+
for op_idx, op_lbls in enumerate(graph.operand_labels):
|
|
446
|
+
if op_idx not in subset:
|
|
447
|
+
outside_labels.update(op_lbls)
|
|
448
|
+
|
|
449
|
+
# V at this step = labels_in_subset ∩ (free_labels ∪ outside_labels)
|
|
450
|
+
v_labels = frozenset(labels_in_subset & (graph.free_labels | outside_labels))
|
|
451
|
+
w_labels = frozenset(labels_in_subset - v_labels)
|
|
452
|
+
|
|
453
|
+
id_groups = tuple(
|
|
454
|
+
tuple(sorted(set(g) & subset))
|
|
455
|
+
for g in graph.identical_operand_groups
|
|
456
|
+
if len(set(g) & subset) >= 2
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return _Subgraph(
|
|
460
|
+
u_local=u_local,
|
|
461
|
+
v_labels=v_labels,
|
|
462
|
+
w_labels=w_labels,
|
|
463
|
+
id_groups=id_groups,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class SubgraphSymmetryOracle:
|
|
468
|
+
"""Subset-keyed symmetry oracle for einsum intermediates.
|
|
469
|
+
|
|
470
|
+
One oracle per contract_path call. Symmetries are computed lazily
|
|
471
|
+
on first access to a subset and cached in memory. Returns a
|
|
472
|
+
``SubsetSymmetry`` with ``.output`` (V-side) and ``.inner`` (W-side).
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
def __init__(
|
|
476
|
+
self,
|
|
477
|
+
operands: list[Any],
|
|
478
|
+
subscript_parts: list[str],
|
|
479
|
+
per_op_groups: list[list[SymmetryGroup] | None],
|
|
480
|
+
output_chars: str,
|
|
481
|
+
) -> None:
|
|
482
|
+
self._graph = _build_bipartite(
|
|
483
|
+
operands=operands,
|
|
484
|
+
subscript_parts=subscript_parts,
|
|
485
|
+
per_op_groups=per_op_groups,
|
|
486
|
+
output_chars=output_chars,
|
|
487
|
+
)
|
|
488
|
+
self._cache: dict[frozenset[int], SubsetSymmetry] = {}
|
|
489
|
+
|
|
490
|
+
def sym(self, subset: frozenset[int]) -> SubsetSymmetry:
|
|
491
|
+
cached = self._cache.get(subset, _MISSING)
|
|
492
|
+
if cached is not _MISSING:
|
|
493
|
+
return cached # type: ignore[return-value]
|
|
494
|
+
result = _compute_subset_symmetry(self._graph, subset)
|
|
495
|
+
self._cache[subset] = result
|
|
496
|
+
return result
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _compute_subset_symmetry(
|
|
500
|
+
graph: EinsumBipartite,
|
|
501
|
+
subset: frozenset[int],
|
|
502
|
+
) -> SubsetSymmetry:
|
|
503
|
+
sub = _induce_subgraph(graph, subset)
|
|
504
|
+
if not sub.v_labels and not sub.w_labels:
|
|
505
|
+
return SubsetSymmetry(None, None)
|
|
506
|
+
|
|
507
|
+
# Column fingerprints for π derivation.
|
|
508
|
+
row_order = sub.u_local
|
|
509
|
+
all_labels = sub.v_labels | sub.w_labels
|
|
510
|
+
col_of: dict[str, tuple[int, ...]] = {}
|
|
511
|
+
for label in all_labels:
|
|
512
|
+
col_of[label] = tuple(graph.incidence[u].get(label, 0) for u in row_order)
|
|
513
|
+
|
|
514
|
+
fp_to_labels: dict[tuple[int, ...], set[str]] = {}
|
|
515
|
+
for lbl, fp in col_of.items():
|
|
516
|
+
fp_to_labels.setdefault(fp, set()).add(lbl)
|
|
517
|
+
|
|
518
|
+
# Collect exact π generators via σ-loop.
|
|
519
|
+
v_perms, w_perms, joint_perms = _collect_pi_permutations(
|
|
520
|
+
graph, sub, row_order, col_of, fp_to_labels
|
|
521
|
+
)
|
|
522
|
+
v_sorted = tuple(sorted(sub.v_labels))
|
|
523
|
+
w_sorted = tuple(sorted(sub.w_labels))
|
|
524
|
+
all_sorted = tuple(sorted(all_labels))
|
|
525
|
+
|
|
526
|
+
# Build V-side group (only from σ-loop results, no fast path).
|
|
527
|
+
v_group: SymmetryGroup | None = None
|
|
528
|
+
if v_perms:
|
|
529
|
+
v_group = SymmetryGroup(*v_perms, axes=tuple(range(len(v_sorted))))
|
|
530
|
+
v_group._labels = v_sorted
|
|
531
|
+
|
|
532
|
+
# Build W-side group (only from σ-loop results, no fast path).
|
|
533
|
+
w_group: SymmetryGroup | None = None
|
|
534
|
+
if w_perms:
|
|
535
|
+
w_group = SymmetryGroup(*w_perms, axes=tuple(range(len(w_sorted))))
|
|
536
|
+
w_group._labels = w_sorted
|
|
537
|
+
|
|
538
|
+
# Build joint group on sorted(V ∪ W).
|
|
539
|
+
joint_group: SymmetryGroup | None = None
|
|
540
|
+
if joint_perms:
|
|
541
|
+
joint_group = SymmetryGroup(*joint_perms, axes=tuple(range(len(all_sorted))))
|
|
542
|
+
joint_group._labels = all_sorted
|
|
543
|
+
|
|
544
|
+
return SubsetSymmetry(output=v_group, inner=w_group, joint=joint_group)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Symmetry-aware cost helpers for opt_einsum contraction paths.
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- unique_elements / compute_unique_size: count distinct elements under symmetry.
|
|
5
|
+
- symmetric_flop_count: FLOP estimate reduced by output symmetry.
|
|
6
|
+
|
|
7
|
+
Detection of symmetries is handled by ``_subgraph_symmetry.SubgraphSymmetryOracle``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Collection
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from flopscope._perm_group import SymmetryGroup
|
|
16
|
+
|
|
17
|
+
from ._helpers import flop_count
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SubsetSymmetry:
|
|
22
|
+
"""Symmetry info for one contraction subset, split by V/W.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
output : SymmetryGroup or None
|
|
27
|
+
Exact permutation group for the output (V) labels.
|
|
28
|
+
inner : SymmetryGroup or None
|
|
29
|
+
Exact permutation group for the inner (W) labels.
|
|
30
|
+
joint : SymmetryGroup or None
|
|
31
|
+
Value-preserving joint group acting on all V ∪ W labels together
|
|
32
|
+
(in sorted order). Exposes cross-step identity-swap symmetries
|
|
33
|
+
that per-projection fingerprints miss. ``_labels`` is set to the
|
|
34
|
+
sorted tuple of all subset labels.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
output: SymmetryGroup | None
|
|
38
|
+
inner: SymmetryGroup | None
|
|
39
|
+
joint: SymmetryGroup | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def unique_elements(
|
|
43
|
+
indices: frozenset[str],
|
|
44
|
+
size_dict: dict[str, int],
|
|
45
|
+
perm_group: SymmetryGroup | None = None,
|
|
46
|
+
) -> int:
|
|
47
|
+
"""Count distinct elements of a tensor with the given symmetry.
|
|
48
|
+
|
|
49
|
+
When perm_group is provided, uses Burnside's lemma for exact counting.
|
|
50
|
+
When None, returns the dense count (product of all sizes).
|
|
51
|
+
"""
|
|
52
|
+
if not indices:
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
if perm_group is not None:
|
|
56
|
+
if perm_group._labels is not None:
|
|
57
|
+
label_list = list(perm_group._labels)
|
|
58
|
+
else:
|
|
59
|
+
label_list = sorted(indices)[: perm_group.degree]
|
|
60
|
+
pg_size_dict: dict[int, int] = {}
|
|
61
|
+
accounted: set[str] = set()
|
|
62
|
+
for i, lbl in enumerate(label_list):
|
|
63
|
+
pg_size_dict[i] = size_dict[lbl]
|
|
64
|
+
accounted.add(lbl)
|
|
65
|
+
count = perm_group.burnside_unique_count(pg_size_dict)
|
|
66
|
+
for idx in indices:
|
|
67
|
+
if idx not in accounted:
|
|
68
|
+
count *= size_dict[idx]
|
|
69
|
+
return count
|
|
70
|
+
|
|
71
|
+
# No symmetry -- dense count.
|
|
72
|
+
count = 1
|
|
73
|
+
for idx in indices:
|
|
74
|
+
count *= size_dict[idx]
|
|
75
|
+
return count
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
compute_unique_size = unique_elements
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def symmetric_flop_count(
|
|
82
|
+
idx_contraction: Collection[str],
|
|
83
|
+
inner: bool,
|
|
84
|
+
num_terms: int,
|
|
85
|
+
size_dictionary: dict[str, int],
|
|
86
|
+
*,
|
|
87
|
+
output_group: SymmetryGroup | None = None,
|
|
88
|
+
output_indices: frozenset[str] | None = None,
|
|
89
|
+
inner_group: SymmetryGroup | None = None,
|
|
90
|
+
inner_indices: frozenset[str] | None = None,
|
|
91
|
+
use_inner_symmetry: bool = True,
|
|
92
|
+
per_operand_free_counts: tuple[int, ...] | None = None,
|
|
93
|
+
) -> int:
|
|
94
|
+
r"""FLOP count for a symmetric tensor contraction.
|
|
95
|
+
|
|
96
|
+
Computes the direct-evaluation cost, reduced by the output and inner
|
|
97
|
+
symmetry ratios when the corresponding groups are provided.
|
|
98
|
+
|
|
99
|
+
Inner symmetry reduction is applied only when ``use_inner_symmetry``
|
|
100
|
+
is True and **all** labels in ``inner_group`` are present as
|
|
101
|
+
contracted indices in this specific step. If any W-group labels were
|
|
102
|
+
contracted at earlier steps and are no longer present, the inner
|
|
103
|
+
reduction is skipped.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
use_inner_symmetry : bool, optional
|
|
108
|
+
Whether to apply the inner (W-side) symmetry reduction.
|
|
109
|
+
Default ``True``.
|
|
110
|
+
per_operand_free_counts : tuple of int, optional
|
|
111
|
+
Number of free (non-contracted) indices contributed by each
|
|
112
|
+
operand. For a pairwise contraction this is ``(s, t)``.
|
|
113
|
+
Reserved for future cost models; not currently used.
|
|
114
|
+
"""
|
|
115
|
+
from ._helpers import compute_size_by_dict
|
|
116
|
+
|
|
117
|
+
# --- Direct-evaluation estimate ---
|
|
118
|
+
cost = flop_count(idx_contraction, inner, num_terms, size_dictionary)
|
|
119
|
+
|
|
120
|
+
if output_group is not None and output_indices is not None:
|
|
121
|
+
total = compute_size_by_dict(output_indices, size_dictionary)
|
|
122
|
+
unique = unique_elements(
|
|
123
|
+
output_indices, size_dictionary, perm_group=output_group
|
|
124
|
+
)
|
|
125
|
+
cost = cost * unique // total
|
|
126
|
+
|
|
127
|
+
# Inner symmetry: only apply when ALL group labels are being contracted
|
|
128
|
+
# at this step. The oracle's W-group may span labels from prior steps
|
|
129
|
+
# that no longer exist in the current pairwise contraction.
|
|
130
|
+
if use_inner_symmetry and inner_group is not None and inner_indices is not None:
|
|
131
|
+
group_labels = set(inner_group._labels) if inner_group._labels else set()
|
|
132
|
+
if group_labels and group_labels <= set(inner_indices):
|
|
133
|
+
total_inner = compute_size_by_dict(inner_indices, size_dictionary)
|
|
134
|
+
if total_inner > 0:
|
|
135
|
+
unique_inner = unique_elements(
|
|
136
|
+
inner_indices, size_dictionary, perm_group=inner_group
|
|
137
|
+
)
|
|
138
|
+
cost = cost * unique_inner // total_inner
|
|
139
|
+
|
|
140
|
+
return max(cost, 1)
|