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,1414 @@
|
|
|
1
|
+
"""AccumulationCost orchestrator + per-component cost wrapping.
|
|
2
|
+
|
|
3
|
+
Aggregates the ladder primitive (compute_accumulation) into
|
|
4
|
+
einsum-shaped cost reports. Future reduction code reuses run_ladder_per_component
|
|
5
|
+
and adds its own aggregator (aggregate_reduction) that uses different cost arithmetic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from dataclasses import replace as _dc_replace
|
|
14
|
+
|
|
15
|
+
from ._burnside import size_aware_burnside
|
|
16
|
+
from ._components import Component
|
|
17
|
+
from ._ladder import (
|
|
18
|
+
AccumulationResult,
|
|
19
|
+
RegimeId,
|
|
20
|
+
RegimeStep,
|
|
21
|
+
Shape,
|
|
22
|
+
compute_accumulation,
|
|
23
|
+
)
|
|
24
|
+
from ._output_orbit import restrict_stabilizer_to_positions
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_symmetric_proxy(shape, sym):
|
|
28
|
+
"""Build a proxy operand for reduction_accumulation_cost.
|
|
29
|
+
|
|
30
|
+
reduction_accumulation_cost only inspects shape + symmetry on its input;
|
|
31
|
+
a numpy.empty(shape) wrapped in a SymmetricTensor (if sym is non-None) is
|
|
32
|
+
sufficient and avoids materializing actual values. We bypass validation
|
|
33
|
+
(as_symmetric would raise for uninitialized data) since we only need the
|
|
34
|
+
symmetry metadata, not correct tensor values. Used by per-step
|
|
35
|
+
pre-reduction detection to compute Tier-1 reduction cost without owning
|
|
36
|
+
the operand data itself.
|
|
37
|
+
"""
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
arr = np.empty(shape)
|
|
41
|
+
if sym is None:
|
|
42
|
+
return arr
|
|
43
|
+
from flopscope._symmetric import SymmetricTensor
|
|
44
|
+
|
|
45
|
+
return SymmetricTensor(arr, symmetry=sym)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compute_step_cost_from_joint_group(
|
|
49
|
+
joint_group: object,
|
|
50
|
+
v_labels: tuple[str, ...],
|
|
51
|
+
w_labels: tuple[str, ...],
|
|
52
|
+
sizes: dict[str, int],
|
|
53
|
+
num_terms: int,
|
|
54
|
+
dimino_budget: int,
|
|
55
|
+
) -> int | None:
|
|
56
|
+
"""Per-step cost via Burnside on the merged-subset's joint group.
|
|
57
|
+
|
|
58
|
+
Sprint 2 / Cat C: complements the per-input fingerprint cost by handling
|
|
59
|
+
cross-step identity-swap cases (e.g. §5(a) step 2 = 160 → 55) that
|
|
60
|
+
per-input cannot see because intermediates lose their "from the same
|
|
61
|
+
original operand" identity.
|
|
62
|
+
|
|
63
|
+
Cost formula (Regime 1 / functionalProjection):
|
|
64
|
+
M = |orbits of joint_group on (V ∪ W)-tuples|
|
|
65
|
+
O = |orbits of joint_group's V-projection on V-tuples|
|
|
66
|
+
total = (num_terms − 1) · M + M − O (for k = 2: 2M − O)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The computed total cost, or None when:
|
|
70
|
+
- joint_group is None / has no generators (trivial),
|
|
71
|
+
- V is not preserved setwise by joint_group (Regime 2 — caller
|
|
72
|
+
must fall back to per-input path),
|
|
73
|
+
- dimino enumeration exceeds dimino_budget,
|
|
74
|
+
- joint_group's _labels does not cover (v_labels + w_labels).
|
|
75
|
+
"""
|
|
76
|
+
from flopscope._config import get_setting, set_setting
|
|
77
|
+
from flopscope._perm_group import _dimino
|
|
78
|
+
from flopscope._perm_group import _PermutationCompat as _Perm
|
|
79
|
+
|
|
80
|
+
if joint_group is None:
|
|
81
|
+
return None
|
|
82
|
+
if not getattr(joint_group, "generators", None):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
joint_labels = getattr(joint_group, "_labels", None)
|
|
86
|
+
if joint_labels is None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
canonical_labels = tuple(v_labels) + tuple(w_labels)
|
|
90
|
+
if set(joint_labels) != set(canonical_labels):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
n_v = len(v_labels)
|
|
94
|
+
n_total = len(canonical_labels)
|
|
95
|
+
canonical_idx = {lbl: i for i, lbl in enumerate(canonical_labels)}
|
|
96
|
+
joint_idx = {lbl: i for i, lbl in enumerate(joint_labels)}
|
|
97
|
+
|
|
98
|
+
canonical_gens: list[_Perm] = []
|
|
99
|
+
for gen in joint_group.generators: # type: ignore[union-attr]
|
|
100
|
+
arr = gen.array_form
|
|
101
|
+
canonical_perm = [0] * n_total
|
|
102
|
+
for lbl in canonical_labels:
|
|
103
|
+
src_jpos = joint_idx[lbl]
|
|
104
|
+
dst_jlbl = joint_labels[arr[src_jpos]]
|
|
105
|
+
canonical_perm[canonical_idx[lbl]] = canonical_idx[dst_jlbl]
|
|
106
|
+
for vpos in range(n_v):
|
|
107
|
+
if canonical_perm[vpos] >= n_v:
|
|
108
|
+
return None
|
|
109
|
+
canonical_gens.append(_Perm(canonical_perm))
|
|
110
|
+
|
|
111
|
+
if not canonical_gens:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Temporarily override dimino_budget to honour the caller's request.
|
|
115
|
+
prev_budget = int(get_setting("dimino_budget")) # type: ignore[arg-type]
|
|
116
|
+
try:
|
|
117
|
+
set_setting("dimino_budget", dimino_budget)
|
|
118
|
+
all_elems = _dimino(tuple(canonical_gens))
|
|
119
|
+
except Exception:
|
|
120
|
+
return None
|
|
121
|
+
finally:
|
|
122
|
+
set_setting("dimino_budget", prev_budget)
|
|
123
|
+
|
|
124
|
+
combined_sizes = tuple(sizes[lbl] for lbl in canonical_labels)
|
|
125
|
+
M = size_aware_burnside(all_elems, combined_sizes)
|
|
126
|
+
|
|
127
|
+
v_elems = [_Perm(list(elem.array_form[:n_v])) for elem in all_elems]
|
|
128
|
+
v_sizes = combined_sizes[:n_v]
|
|
129
|
+
num_v_orbits = size_aware_burnside(v_elems, v_sizes)
|
|
130
|
+
|
|
131
|
+
return (num_terms - 1) * M + M - num_v_orbits
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class ComponentCost:
|
|
136
|
+
"""Per-component cost. alpha is None when this component's regime returned
|
|
137
|
+
unavailable (partition budget exceeded with brute-force disabled by policy)."""
|
|
138
|
+
|
|
139
|
+
labels: tuple[str, ...]
|
|
140
|
+
va: tuple[str, ...]
|
|
141
|
+
wa: tuple[str, ...]
|
|
142
|
+
sizes: tuple[int, ...]
|
|
143
|
+
|
|
144
|
+
m: int
|
|
145
|
+
alpha: int | None
|
|
146
|
+
dense_count: int
|
|
147
|
+
num_output_orbits: int
|
|
148
|
+
|
|
149
|
+
regime_id: RegimeId
|
|
150
|
+
shape: Shape
|
|
151
|
+
|
|
152
|
+
group_name: str
|
|
153
|
+
group_order: int
|
|
154
|
+
|
|
155
|
+
regime_trace: tuple[RegimeStep, ...]
|
|
156
|
+
unavailable_reason: str | None = None
|
|
157
|
+
|
|
158
|
+
def describe(self) -> dict[str, str]:
|
|
159
|
+
"""LaTeX strings built on demand (Task 23 fills in the body)."""
|
|
160
|
+
from ._cost_descriptions import describe_component
|
|
161
|
+
|
|
162
|
+
return describe_component(self)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_ladder_per_component(
|
|
166
|
+
components: Sequence[Component],
|
|
167
|
+
*,
|
|
168
|
+
partition_budget: int,
|
|
169
|
+
) -> tuple[ComponentCost, ...]:
|
|
170
|
+
"""For each Component, run the ladder + Burnside, return ComponentCosts.
|
|
171
|
+
|
|
172
|
+
Pure transformation, no aggregation policy. Reused by both einsum and (future)
|
|
173
|
+
reduction code paths.
|
|
174
|
+
"""
|
|
175
|
+
out: list[ComponentCost] = []
|
|
176
|
+
for c in components:
|
|
177
|
+
result: AccumulationResult = compute_accumulation(
|
|
178
|
+
labels=c.labels,
|
|
179
|
+
va=c.va,
|
|
180
|
+
wa=c.wa,
|
|
181
|
+
elements=c.elements,
|
|
182
|
+
generators=c.generators,
|
|
183
|
+
sizes=c.sizes,
|
|
184
|
+
visible_positions=c.visible_positions,
|
|
185
|
+
partition_budget=partition_budget,
|
|
186
|
+
)
|
|
187
|
+
# M is always computable via Burnside, even when α is unavailable.
|
|
188
|
+
if c.elements and len(c.elements) > 0:
|
|
189
|
+
m = size_aware_burnside(c.elements, c.sizes)
|
|
190
|
+
else:
|
|
191
|
+
m = math.prod(c.sizes) if c.sizes else 1
|
|
192
|
+
dense_count = math.prod(c.sizes) if c.sizes else 1
|
|
193
|
+
|
|
194
|
+
# num_output_orbits: orbits of the visible-label tuples under the
|
|
195
|
+
# output stabilizer (the subgroup of G that preserves V setwise,
|
|
196
|
+
# restricted to V). Used by aggregate_einsum to apply the same
|
|
197
|
+
# "first cell of each orbit is a free copy" off-by-one correction
|
|
198
|
+
# that reduction_accumulation_cost already applies.
|
|
199
|
+
v_sizes = tuple(c.sizes[p] for p in c.visible_positions)
|
|
200
|
+
if not v_sizes:
|
|
201
|
+
num_output_orbits = 1
|
|
202
|
+
elif c.elements and len(c.elements) > 0:
|
|
203
|
+
h_elements = restrict_stabilizer_to_positions(
|
|
204
|
+
c.elements, c.visible_positions
|
|
205
|
+
)
|
|
206
|
+
num_output_orbits = size_aware_burnside(h_elements, v_sizes)
|
|
207
|
+
else:
|
|
208
|
+
num_output_orbits = math.prod(v_sizes)
|
|
209
|
+
|
|
210
|
+
unavailable_reason: str | None = None
|
|
211
|
+
if result.regime_id == "unavailable":
|
|
212
|
+
# The "unavailable" trace step's reason is the ladder's last word.
|
|
213
|
+
unavailable_step = next(
|
|
214
|
+
(s for s in result.trace if s.regime_id == "unavailable"),
|
|
215
|
+
None,
|
|
216
|
+
)
|
|
217
|
+
if unavailable_step is not None:
|
|
218
|
+
unavailable_reason = unavailable_step.reason
|
|
219
|
+
|
|
220
|
+
out.append(
|
|
221
|
+
ComponentCost(
|
|
222
|
+
labels=c.labels,
|
|
223
|
+
va=c.va,
|
|
224
|
+
wa=c.wa,
|
|
225
|
+
sizes=c.sizes,
|
|
226
|
+
m=m,
|
|
227
|
+
alpha=result.count,
|
|
228
|
+
dense_count=dense_count,
|
|
229
|
+
num_output_orbits=num_output_orbits,
|
|
230
|
+
regime_id=result.regime_id,
|
|
231
|
+
shape=result.shape,
|
|
232
|
+
group_name=c.group_name,
|
|
233
|
+
group_order=c.order,
|
|
234
|
+
regime_trace=result.trace,
|
|
235
|
+
unavailable_reason=unavailable_reason,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return tuple(out)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── AccumulationCost + aggregate_einsum ──────────────────────────────
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
import warnings
|
|
245
|
+
|
|
246
|
+
from flopscope._opt_einsum._contract import PreReduction
|
|
247
|
+
from flopscope.errors import CostFallbackWarning
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class AccumulationCost:
|
|
252
|
+
"""Whole-einsum cost. When any component is unavailable, total falls back to
|
|
253
|
+
the dense baseline (k · dense_baseline) and a CostFallbackWarning fires."""
|
|
254
|
+
|
|
255
|
+
total: int
|
|
256
|
+
mu: int | None
|
|
257
|
+
alpha: int | None
|
|
258
|
+
m_total: int
|
|
259
|
+
dense_baseline: int
|
|
260
|
+
num_terms: int
|
|
261
|
+
|
|
262
|
+
per_component: tuple[ComponentCost, ...]
|
|
263
|
+
|
|
264
|
+
fallback_used: bool
|
|
265
|
+
unavailable_components: tuple[int, ...] = ()
|
|
266
|
+
unavailable_reason: str | None = None
|
|
267
|
+
|
|
268
|
+
# NEW: path-aware decomposition. Empty for k<=2 (no path walked).
|
|
269
|
+
per_step: tuple[AccumulationCost, ...] = ()
|
|
270
|
+
path: tuple[tuple[int, ...], ...] | None = None
|
|
271
|
+
|
|
272
|
+
# Sprint 3 (#55): per-step pre-reductions, one tuple per step (empty when no isolation).
|
|
273
|
+
pre_reductions_per_step: tuple[tuple[PreReduction, ...], ...] = ()
|
|
274
|
+
|
|
275
|
+
# Sprint 4: which cost category produced this step's total ("per-input",
|
|
276
|
+
# "joint-burnside", or "output-burnside"). None for aggregate (multi-step)
|
|
277
|
+
# AccumulationCost objects and pre-Sprint-4 stale instances.
|
|
278
|
+
cost_source: str | None = None
|
|
279
|
+
|
|
280
|
+
def describe(self) -> dict:
|
|
281
|
+
"""Human-readable + LaTeX summary, built on demand."""
|
|
282
|
+
from ._cost_descriptions import describe_total
|
|
283
|
+
|
|
284
|
+
return describe_total(self)
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def savings_ratio(self) -> float:
|
|
288
|
+
"""total / (k · dense_baseline). 1.0 means no savings; lower is better."""
|
|
289
|
+
denom = self.num_terms * self.dense_baseline
|
|
290
|
+
return self.total / denom if denom > 0 else 1.0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def aggregate_einsum(
|
|
294
|
+
component_costs: Sequence[ComponentCost],
|
|
295
|
+
*,
|
|
296
|
+
num_terms: int,
|
|
297
|
+
dense_baseline: int,
|
|
298
|
+
) -> AccumulationCost:
|
|
299
|
+
"""Aggregate per-component costs into the einsum cost:
|
|
300
|
+
``total = (k-1)·∏M + ∏α − ∏num_output_orbits``.
|
|
301
|
+
|
|
302
|
+
The final ``−∏num_output_orbits`` term applies the same off-by-one
|
|
303
|
+
correction that ``reduction_accumulation_cost`` uses: the first cell of
|
|
304
|
+
each output orbit is a free copy, and only the remaining accumulations
|
|
305
|
+
cost. When there is no actual reduction (``∏α == ∏num_output_orbits``),
|
|
306
|
+
the α contribution collapses to zero, leaving only the ``(k-1)·∏M``
|
|
307
|
+
multiplication chain.
|
|
308
|
+
|
|
309
|
+
Fallback policy: if any component has alpha=None, total = k · dense_baseline
|
|
310
|
+
(the no-symmetry direct-event count) and a CostFallbackWarning fires.
|
|
311
|
+
"""
|
|
312
|
+
failing = [i for i, c in enumerate(component_costs) if c.alpha is None]
|
|
313
|
+
|
|
314
|
+
m_total = 1
|
|
315
|
+
for c in component_costs:
|
|
316
|
+
m_total *= c.m
|
|
317
|
+
|
|
318
|
+
if not failing:
|
|
319
|
+
alpha_product = 1
|
|
320
|
+
output_orbit_product = 1
|
|
321
|
+
for c in component_costs:
|
|
322
|
+
assert c.alpha is not None # for type narrowing
|
|
323
|
+
alpha_product *= c.alpha
|
|
324
|
+
output_orbit_product *= c.num_output_orbits
|
|
325
|
+
mu = (num_terms - 1) * m_total
|
|
326
|
+
total = mu + alpha_product - output_orbit_product
|
|
327
|
+
return AccumulationCost(
|
|
328
|
+
total=total,
|
|
329
|
+
mu=mu,
|
|
330
|
+
alpha=alpha_product,
|
|
331
|
+
m_total=m_total,
|
|
332
|
+
dense_baseline=dense_baseline,
|
|
333
|
+
num_terms=num_terms,
|
|
334
|
+
per_component=tuple(component_costs),
|
|
335
|
+
fallback_used=False,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Fallback: charge dense.
|
|
339
|
+
fallback_total = num_terms * dense_baseline
|
|
340
|
+
first_failing = component_costs[failing[0]]
|
|
341
|
+
reason = first_failing.unavailable_reason or "partition_budget exceeded"
|
|
342
|
+
failing_labels = ", ".join(first_failing.labels)
|
|
343
|
+
warnings.warn(
|
|
344
|
+
CostFallbackWarning(
|
|
345
|
+
f"einsum: component {list(failing)} ({failing_labels}) returned "
|
|
346
|
+
f"unavailable — charging dense cost {fallback_total} = "
|
|
347
|
+
f"{num_terms} × {dense_baseline}. Failing reason: {reason}. "
|
|
348
|
+
f"Raise via flopscope.configure(partition_budget=...) to attempt "
|
|
349
|
+
f"exact counting."
|
|
350
|
+
),
|
|
351
|
+
stacklevel=4,
|
|
352
|
+
)
|
|
353
|
+
return AccumulationCost(
|
|
354
|
+
total=fallback_total,
|
|
355
|
+
mu=None,
|
|
356
|
+
alpha=None,
|
|
357
|
+
m_total=m_total,
|
|
358
|
+
dense_baseline=dense_baseline,
|
|
359
|
+
num_terms=num_terms,
|
|
360
|
+
per_component=tuple(component_costs),
|
|
361
|
+
fallback_used=True,
|
|
362
|
+
unavailable_components=tuple(failing),
|
|
363
|
+
unavailable_reason=reason,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ── End-to-end orchestrator ──────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
from collections.abc import Sequence as _Seq
|
|
371
|
+
from typing import Any as _Any
|
|
372
|
+
from typing import cast as _cast
|
|
373
|
+
|
|
374
|
+
from ._bipartite import build_bipartite, build_incidence_matrix
|
|
375
|
+
from ._components import decompose_into_components
|
|
376
|
+
from ._detection import build_full_group, run_sigma_loop
|
|
377
|
+
from ._wreath import enumerate_wreath
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _build_size_map(
|
|
381
|
+
input_parts: _Seq[str],
|
|
382
|
+
shapes: _Seq[_Seq[int]],
|
|
383
|
+
) -> dict[str, int]:
|
|
384
|
+
"""Build label → size from operand shapes. Validates that each label
|
|
385
|
+
appears with consistent sizes across operands."""
|
|
386
|
+
size_map: dict[str, int] = {}
|
|
387
|
+
for part, shape in zip(input_parts, shapes, strict=True):
|
|
388
|
+
for label, dim in zip(part, shape, strict=True):
|
|
389
|
+
existing = size_map.get(label)
|
|
390
|
+
if existing is not None and existing != dim:
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"label '{label}' has inconsistent sizes {existing} and {dim} "
|
|
393
|
+
f"across operands"
|
|
394
|
+
)
|
|
395
|
+
size_map[label] = dim
|
|
396
|
+
return size_map
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _operand_names_from_identity_pattern(
|
|
400
|
+
num_ops: int,
|
|
401
|
+
identity_pattern: tuple[tuple[int, ...], ...] | None,
|
|
402
|
+
) -> tuple[str, ...]:
|
|
403
|
+
"""Generate operand names that respect the identity pattern.
|
|
404
|
+
Operands sharing the same id (per identity_pattern) get the same name."""
|
|
405
|
+
if identity_pattern is None:
|
|
406
|
+
return tuple(f"op_{i}" for i in range(num_ops))
|
|
407
|
+
name_of: dict[int, str] = {}
|
|
408
|
+
for group in identity_pattern:
|
|
409
|
+
shared_name = f"op_grp_{group[0]}"
|
|
410
|
+
for pos in group:
|
|
411
|
+
name_of[pos] = shared_name
|
|
412
|
+
return tuple(name_of.get(i, f"op_{i}") for i in range(num_ops))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _per_op_symmetry_for_wreath(sym: _Any) -> _Any:
|
|
416
|
+
"""Pass declared symmetry through to enumerate_h. SymmetryGroup objects
|
|
417
|
+
pass through unchanged; strings (like 'symmetric') also pass through."""
|
|
418
|
+
return sym
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _walk_path_and_aggregate(
|
|
422
|
+
*,
|
|
423
|
+
canonical_subscripts: str,
|
|
424
|
+
input_parts: _Seq[str],
|
|
425
|
+
output_subscript: str,
|
|
426
|
+
shapes: _Seq[_Seq[int]],
|
|
427
|
+
per_op_symmetries: _Seq[_Any],
|
|
428
|
+
identity_pattern: tuple[tuple[int, ...], ...] | None,
|
|
429
|
+
partition_budget: int,
|
|
430
|
+
dense_baseline: int,
|
|
431
|
+
full_expression_component_costs: tuple[ComponentCost, ...] | None = None,
|
|
432
|
+
) -> AccumulationCost:
|
|
433
|
+
"""Walk opt_einsum's binary contraction path and sum per-step costs.
|
|
434
|
+
|
|
435
|
+
Decomposes a k>=3 einsum into binary contractions via opt_einsum.contract_path.
|
|
436
|
+
Each binary step calls compute_accumulation_cost recursively (k=2 path),
|
|
437
|
+
treating intermediate tensors as dense (no symmetry carried forward).
|
|
438
|
+
|
|
439
|
+
``full_expression_component_costs``: the ComponentCost tuple from the caller's
|
|
440
|
+
wreath/sigma computation of the FULL k-ary expression. Stored verbatim in
|
|
441
|
+
per_component so that JS-parity tests (which inspect per_component directly)
|
|
442
|
+
remain unaffected by the path decomposition.
|
|
443
|
+
|
|
444
|
+
Returns an AccumulationCost with per_step and path populated.
|
|
445
|
+
"""
|
|
446
|
+
import opt_einsum as _oe
|
|
447
|
+
|
|
448
|
+
from flopscope._opt_einsum._contract import build_path_info
|
|
449
|
+
|
|
450
|
+
num_ops = len(input_parts)
|
|
451
|
+
|
|
452
|
+
# Build shape-only operands for opt_einsum (it only needs shape info).
|
|
453
|
+
# We use shapes=True to avoid materializing tensors.
|
|
454
|
+
import numpy as _np
|
|
455
|
+
|
|
456
|
+
dummy_operands = [_np.empty(shape) for shape in shapes]
|
|
457
|
+
upstream_path, upstream_info = _oe.contract_path(
|
|
458
|
+
canonical_subscripts,
|
|
459
|
+
*dummy_operands,
|
|
460
|
+
optimize="auto",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
path_info = build_path_info(
|
|
464
|
+
upstream_path,
|
|
465
|
+
upstream_info,
|
|
466
|
+
size_dict=upstream_info.size_dict,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Build SubgraphSymmetryOracle for the full expression so per-step
|
|
470
|
+
# binary contractions inherit their symmetry from the declared groups
|
|
471
|
+
# rather than treating every intermediate as dense.
|
|
472
|
+
from flopscope._opt_einsum._subgraph_symmetry import SubgraphSymmetryOracle
|
|
473
|
+
|
|
474
|
+
def _group_to_oracle_list(sym: _Any, subscript: str) -> list | None:
|
|
475
|
+
"""Convert a per_op_symmetry entry to the oracle's per_op_groups format.
|
|
476
|
+
|
|
477
|
+
The oracle's Source A generator loop requires ``group._labels`` to be
|
|
478
|
+
set (the subscript chars the group acts on, in axis order). When the
|
|
479
|
+
declared SymmetryGroup has ``axes`` but ``_labels is None``, we derive
|
|
480
|
+
_labels from the operand subscript and the group's axes mapping.
|
|
481
|
+
"""
|
|
482
|
+
from flopscope._perm_group import SymmetryGroup as _SG
|
|
483
|
+
|
|
484
|
+
if sym is None:
|
|
485
|
+
return None
|
|
486
|
+
if not isinstance(sym, _SG):
|
|
487
|
+
# String symmetries (e.g. 'symmetric') are not SymmetryGroup objects;
|
|
488
|
+
# skip them here — the oracle only consumes SymmetryGroup generators.
|
|
489
|
+
return None
|
|
490
|
+
# Ensure _labels is set so the oracle's Source A generator loop can
|
|
491
|
+
# map group-axis positions to subscript characters.
|
|
492
|
+
if sym._labels is None and sym.axes is not None:
|
|
493
|
+
# axes[g_pos] = tensor axis index; subscript[axis_idx] = char.
|
|
494
|
+
try:
|
|
495
|
+
derived_labels = tuple(subscript[ax] for ax in sym.axes)
|
|
496
|
+
except IndexError:
|
|
497
|
+
derived_labels = None
|
|
498
|
+
if derived_labels is not None:
|
|
499
|
+
# Build a new group with _labels set (avoid mutating the original).
|
|
500
|
+
import copy as _copy
|
|
501
|
+
|
|
502
|
+
labeled_group = _copy.copy(sym)
|
|
503
|
+
labeled_group._labels = derived_labels
|
|
504
|
+
return [labeled_group]
|
|
505
|
+
return [sym]
|
|
506
|
+
|
|
507
|
+
# Build oracle operands that mirror the original identity pattern:
|
|
508
|
+
# identical operands (same Python id in the original call) must share
|
|
509
|
+
# the same Python object here so the oracle's Source B generator (identical-
|
|
510
|
+
# operand swaps) fires correctly for subset queries.
|
|
511
|
+
oracle_operands = list(dummy_operands) # start with distinct fresh objects
|
|
512
|
+
if identity_pattern:
|
|
513
|
+
for group in identity_pattern:
|
|
514
|
+
# All positions in `group` referred to the same object originally.
|
|
515
|
+
# Reuse the dummy for the first position for all positions in the group.
|
|
516
|
+
canonical_obj = oracle_operands[group[0]]
|
|
517
|
+
for pos in group[1:]:
|
|
518
|
+
oracle_operands[pos] = canonical_obj
|
|
519
|
+
|
|
520
|
+
oracle = SubgraphSymmetryOracle(
|
|
521
|
+
operands=oracle_operands,
|
|
522
|
+
subscript_parts=list(input_parts),
|
|
523
|
+
per_op_groups=[
|
|
524
|
+
_group_to_oracle_list(s, sub)
|
|
525
|
+
for s, sub in zip(per_op_symmetries, input_parts, strict=False)
|
|
526
|
+
],
|
|
527
|
+
output_chars=output_subscript,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def _subset_sym_fingerprint(subset: frozenset[int], step_subscript: str) -> tuple:
|
|
531
|
+
"""Return an accumulation-cache fingerprint for a step-input subset.
|
|
532
|
+
|
|
533
|
+
Queries the oracle for the V-side (output) group of the subset of
|
|
534
|
+
original operands, then serialises it in the (axes, gens) format
|
|
535
|
+
expected by get_accumulation_cost_cached's sym_fingerprint.
|
|
536
|
+
Returns None (dense) when the oracle finds no symmetry.
|
|
537
|
+
|
|
538
|
+
Oracle groups encode generators in label-sorted space (_labels gives the
|
|
539
|
+
sorted char tuple and each generator acts on positions of that tuple).
|
|
540
|
+
The cache reconstructs a group with _labels derived from the step
|
|
541
|
+
subscript in axis order, so the generator would be misinterpreted unless
|
|
542
|
+
we convert it to tensor-axis space first.
|
|
543
|
+
"""
|
|
544
|
+
from flopscope._perm_group import SymmetryGroup as _SG
|
|
545
|
+
from flopscope._perm_group import _PermutationCompat as _Perm
|
|
546
|
+
|
|
547
|
+
ss = oracle.sym(subset)
|
|
548
|
+
grp = ss.output # V-side (free-label) group for this subset
|
|
549
|
+
if grp is None or not isinstance(grp, _SG):
|
|
550
|
+
return None # type: ignore[return-value]
|
|
551
|
+
if not grp.generators:
|
|
552
|
+
return None # type: ignore[return-value]
|
|
553
|
+
|
|
554
|
+
# Convert oracle's label-sorted generators to tensor-axis-space so that
|
|
555
|
+
# when the cache reconstructs the group (with _labels derived from the
|
|
556
|
+
# step subscript in axis order), the generator is correctly interpreted.
|
|
557
|
+
if grp._labels is not None:
|
|
558
|
+
labels = grp._labels # sorted char tuple
|
|
559
|
+
char_to_axis = {c: i for i, c in enumerate(step_subscript)}
|
|
560
|
+
label_to_lpos = {lbl: k for k, lbl in enumerate(labels)}
|
|
561
|
+
new_gens = []
|
|
562
|
+
for gen in grp.generators:
|
|
563
|
+
arr = list(gen.array_form)
|
|
564
|
+
axis_gen = list(range(len(step_subscript)))
|
|
565
|
+
for ax, char in enumerate(step_subscript):
|
|
566
|
+
if char in label_to_lpos:
|
|
567
|
+
lpos = label_to_lpos[char]
|
|
568
|
+
target_char = labels[arr[lpos]]
|
|
569
|
+
if target_char in char_to_axis:
|
|
570
|
+
axis_gen[ax] = char_to_axis[target_char]
|
|
571
|
+
new_gens.append(_Perm(axis_gen))
|
|
572
|
+
axes = tuple(range(len(step_subscript)))
|
|
573
|
+
gens = tuple(tuple(g.array_form) for g in new_gens)
|
|
574
|
+
else:
|
|
575
|
+
axes = grp.axes if grp.axes is not None else tuple(range(grp.degree))
|
|
576
|
+
gens = tuple(tuple(g.array_form) for g in grp.generators)
|
|
577
|
+
|
|
578
|
+
if not gens:
|
|
579
|
+
return None # type: ignore[return-value]
|
|
580
|
+
return (axes, gens)
|
|
581
|
+
|
|
582
|
+
# SSA-to-subset: tracks which original operand positions each current
|
|
583
|
+
# operand covers. Starts as singletons; merged as the path progresses.
|
|
584
|
+
# We walk path_info.path in parallel with path_info.steps.
|
|
585
|
+
current_subsets: list[frozenset[int]] = [frozenset({i}) for i in range(num_ops)]
|
|
586
|
+
|
|
587
|
+
from ._cache import get_accumulation_cost_cached
|
|
588
|
+
|
|
589
|
+
per_step_costs: list[AccumulationCost] = []
|
|
590
|
+
pre_reductions_per_step: list[tuple[PreReduction, ...]] = []
|
|
591
|
+
for step_idx, step in enumerate(path_info.steps):
|
|
592
|
+
step_subscript = step.subscript
|
|
593
|
+
if "->" in step_subscript:
|
|
594
|
+
step_lhs, step_output = step_subscript.split("->", 1)
|
|
595
|
+
else:
|
|
596
|
+
step_lhs, step_output = step_subscript, ""
|
|
597
|
+
step_input_parts = step_lhs.split(",")
|
|
598
|
+
step_shapes = step.input_shapes
|
|
599
|
+
|
|
600
|
+
# Derive which original operands each step input covers.
|
|
601
|
+
# path_info.path[step_idx] gives the current-list positions to contract.
|
|
602
|
+
raw_path_entry = path_info.path[step_idx]
|
|
603
|
+
# Sort descending so that popping by index doesn't shift earlier indices.
|
|
604
|
+
contract_positions = tuple(sorted(raw_path_entry, reverse=True))
|
|
605
|
+
|
|
606
|
+
# Gather the input subsets matching the opt_einsum operand order in the
|
|
607
|
+
# step subscript. opt_einsum pops positions from highest to lowest (to
|
|
608
|
+
# avoid index shifting), so the subscript's left-to-right operand order
|
|
609
|
+
# corresponds to descending position order in the path entry.
|
|
610
|
+
# E.g. path entry (0,1) with subscript "jk,ij->..." means position 1
|
|
611
|
+
# (higher) contributes "jk" first and position 0 contributes "ij" second.
|
|
612
|
+
step_input_subsets = [
|
|
613
|
+
current_subsets[pos]
|
|
614
|
+
for pos in sorted(
|
|
615
|
+
raw_path_entry, reverse=True
|
|
616
|
+
) # descending = subscript order
|
|
617
|
+
]
|
|
618
|
+
|
|
619
|
+
# ── Sprint 3 (#55): per-binary-step pre-reduction of isolated summed labels ──
|
|
620
|
+
# When a label is summed in this step AND appears in only one input, pre-reduce
|
|
621
|
+
# that operand along the isolated axis BEFORE the main contraction. Mirrors
|
|
622
|
+
# PyTorch's sumproduct_pair.
|
|
623
|
+
from flopscope._accumulation._public import (
|
|
624
|
+
_per_op_sym_fingerprint,
|
|
625
|
+
reduction_accumulation_cost,
|
|
626
|
+
)
|
|
627
|
+
from flopscope._symmetry_utils import reduce_group
|
|
628
|
+
|
|
629
|
+
# Per-input symmetries for this step (pre-reduction).
|
|
630
|
+
# For single-original-operand inputs (no upstream contraction), the input's
|
|
631
|
+
# symmetry is per_op_symmetries[only_original]. Multi-original inputs
|
|
632
|
+
# (intermediates) have no declared per-input symmetry.
|
|
633
|
+
def _input_sym(subset):
|
|
634
|
+
if len(subset) == 1:
|
|
635
|
+
only = next(iter(subset))
|
|
636
|
+
return per_op_symmetries[only]
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
left_sym_pre = _input_sym(step_input_subsets[0])
|
|
640
|
+
right_sym_pre = (
|
|
641
|
+
_input_sym(step_input_subsets[1]) if len(step_input_subsets) > 1 else None
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# Detect isolated summed labels.
|
|
645
|
+
summed_set = set("".join(step_input_parts)) - set(step_output)
|
|
646
|
+
left_labels_set = (
|
|
647
|
+
set(step_input_parts[0]) if len(step_input_parts) > 0 else set()
|
|
648
|
+
)
|
|
649
|
+
right_labels_set = (
|
|
650
|
+
set(step_input_parts[1]) if len(step_input_parts) > 1 else set()
|
|
651
|
+
)
|
|
652
|
+
left_only_summed = (left_labels_set & summed_set) - right_labels_set
|
|
653
|
+
right_only_summed = (right_labels_set & summed_set) - left_labels_set
|
|
654
|
+
|
|
655
|
+
pre_reductions_for_step: list[PreReduction] = []
|
|
656
|
+
pre_reduce_cost_total = 0
|
|
657
|
+
|
|
658
|
+
# Defaults: no rewrite.
|
|
659
|
+
effective_left_part = step_input_parts[0]
|
|
660
|
+
effective_right_part = step_input_parts[1] if len(step_input_parts) > 1 else ""
|
|
661
|
+
effective_left_shape = step_shapes[0]
|
|
662
|
+
effective_right_shape = step_shapes[1] if len(step_shapes) > 1 else ()
|
|
663
|
+
effective_left_sym = left_sym_pre
|
|
664
|
+
effective_right_sym = right_sym_pre
|
|
665
|
+
|
|
666
|
+
# Pre-reduce left operand
|
|
667
|
+
if left_only_summed and len(step_input_parts) > 0:
|
|
668
|
+
reduce_axes = tuple(
|
|
669
|
+
i for i, c in enumerate(step_input_parts[0]) if c in left_only_summed
|
|
670
|
+
)
|
|
671
|
+
left_proxy = _build_symmetric_proxy(step_shapes[0], left_sym_pre)
|
|
672
|
+
reduce_cost = reduction_accumulation_cost(
|
|
673
|
+
left_proxy, axis=reduce_axes
|
|
674
|
+
).total
|
|
675
|
+
pre_reduce_cost_total += reduce_cost
|
|
676
|
+
|
|
677
|
+
surviving_subscript = "".join(
|
|
678
|
+
c for c in step_input_parts[0] if c not in left_only_summed
|
|
679
|
+
)
|
|
680
|
+
surviving_shape = tuple(
|
|
681
|
+
s for i, s in enumerate(step_shapes[0]) if i not in set(reduce_axes)
|
|
682
|
+
)
|
|
683
|
+
surviving_sym = reduce_group(
|
|
684
|
+
left_sym_pre,
|
|
685
|
+
ndim=len(step_input_parts[0]),
|
|
686
|
+
axis=reduce_axes,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
effective_left_part = surviving_subscript
|
|
690
|
+
effective_left_shape = surviving_shape
|
|
691
|
+
effective_left_sym = surviving_sym
|
|
692
|
+
pre_reductions_for_step.append(
|
|
693
|
+
PreReduction(
|
|
694
|
+
operand_index=0,
|
|
695
|
+
removed_labels=tuple(sorted(left_only_summed)),
|
|
696
|
+
cost=reduce_cost,
|
|
697
|
+
surviving_subscript=surviving_subscript,
|
|
698
|
+
reduced_symmetry_fingerprint=(
|
|
699
|
+
_per_op_sym_fingerprint(surviving_sym)
|
|
700
|
+
if surviving_sym is not None
|
|
701
|
+
else None
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Pre-reduce right operand
|
|
707
|
+
if right_only_summed and len(step_input_parts) > 1:
|
|
708
|
+
reduce_axes = tuple(
|
|
709
|
+
i for i, c in enumerate(step_input_parts[1]) if c in right_only_summed
|
|
710
|
+
)
|
|
711
|
+
right_proxy = _build_symmetric_proxy(step_shapes[1], right_sym_pre)
|
|
712
|
+
reduce_cost = reduction_accumulation_cost(
|
|
713
|
+
right_proxy, axis=reduce_axes
|
|
714
|
+
).total
|
|
715
|
+
pre_reduce_cost_total += reduce_cost
|
|
716
|
+
|
|
717
|
+
surviving_subscript = "".join(
|
|
718
|
+
c for c in step_input_parts[1] if c not in right_only_summed
|
|
719
|
+
)
|
|
720
|
+
surviving_shape = tuple(
|
|
721
|
+
s for i, s in enumerate(step_shapes[1]) if i not in set(reduce_axes)
|
|
722
|
+
)
|
|
723
|
+
surviving_sym = reduce_group(
|
|
724
|
+
right_sym_pre,
|
|
725
|
+
ndim=len(step_input_parts[1]),
|
|
726
|
+
axis=reduce_axes,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
effective_right_part = surviving_subscript
|
|
730
|
+
effective_right_shape = surviving_shape
|
|
731
|
+
effective_right_sym = surviving_sym
|
|
732
|
+
pre_reductions_for_step.append(
|
|
733
|
+
PreReduction(
|
|
734
|
+
operand_index=1,
|
|
735
|
+
removed_labels=tuple(sorted(right_only_summed)),
|
|
736
|
+
cost=reduce_cost,
|
|
737
|
+
surviving_subscript=surviving_subscript,
|
|
738
|
+
reduced_symmetry_fingerprint=(
|
|
739
|
+
_per_op_sym_fingerprint(surviving_sym)
|
|
740
|
+
if surviving_sym is not None
|
|
741
|
+
else None
|
|
742
|
+
),
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Override step_input_parts / step_shapes to the EFFECTIVE versions so the
|
|
747
|
+
# downstream existing code (per-input fingerprint + cache lookup + Cat C
|
|
748
|
+
# joint-Burnside + V-only fallback) sees the rewritten step.
|
|
749
|
+
if pre_reductions_for_step:
|
|
750
|
+
if len(step_input_parts) >= 2:
|
|
751
|
+
step_input_parts = [effective_left_part, effective_right_part]
|
|
752
|
+
step_shapes = [effective_left_shape, effective_right_shape]
|
|
753
|
+
else:
|
|
754
|
+
step_input_parts = [effective_left_part]
|
|
755
|
+
step_shapes = [effective_left_shape]
|
|
756
|
+
|
|
757
|
+
# Build per-step sym_fingerprint by querying the oracle per input subset.
|
|
758
|
+
# Pass the step subscript so generators can be converted from oracle
|
|
759
|
+
# label-sorted space to tensor-axis space matching the step subscript.
|
|
760
|
+
# Override per-input fingerprints when pre-reduction occurred, since the
|
|
761
|
+
# effective symmetry differs from what _subset_sym_fingerprint would compute
|
|
762
|
+
# by querying the oracle on the (unreduced) subset.
|
|
763
|
+
step_sym_fp_list = []
|
|
764
|
+
for idx, (subset, sub_part) in enumerate(
|
|
765
|
+
zip(step_input_subsets, step_input_parts, strict=False)
|
|
766
|
+
):
|
|
767
|
+
if idx == 0 and left_only_summed:
|
|
768
|
+
step_sym_fp_list.append(
|
|
769
|
+
_per_op_sym_fingerprint(effective_left_sym)
|
|
770
|
+
if effective_left_sym is not None
|
|
771
|
+
else None
|
|
772
|
+
)
|
|
773
|
+
elif idx == 1 and right_only_summed:
|
|
774
|
+
step_sym_fp_list.append(
|
|
775
|
+
_per_op_sym_fingerprint(effective_right_sym)
|
|
776
|
+
if effective_right_sym is not None
|
|
777
|
+
else None
|
|
778
|
+
)
|
|
779
|
+
else:
|
|
780
|
+
step_sym_fp_list.append(_subset_sym_fingerprint(subset, sub_part))
|
|
781
|
+
step_sym_fp: tuple = tuple(step_sym_fp_list)
|
|
782
|
+
|
|
783
|
+
# Derive the step's identity_pattern from the original expression's
|
|
784
|
+
# identity_pattern. A step identity exists only when BOTH inputs are:
|
|
785
|
+
# (a) singleton subsets (original operands, not intermediates), AND
|
|
786
|
+
# (b) in the same identity group in the original expression.
|
|
787
|
+
# The wreath/sigma framework correctly handles identical operands even
|
|
788
|
+
# when they appear with different subscripts in the step — the
|
|
789
|
+
# swap generator combined with per-operand declared symmetry produces
|
|
790
|
+
# the correct joint group.
|
|
791
|
+
step_identity_pattern: tuple[tuple[int, ...], ...] | None = None
|
|
792
|
+
if identity_pattern:
|
|
793
|
+
# Map each singleton original position to its local step index.
|
|
794
|
+
orig_to_local: dict[int, int] = {}
|
|
795
|
+
for local_idx_inner, subset in enumerate(step_input_subsets):
|
|
796
|
+
if len(subset) == 1:
|
|
797
|
+
orig_to_local[next(iter(subset))] = local_idx_inner
|
|
798
|
+
# For each original identity group, restrict to this step's singletons.
|
|
799
|
+
local_groups = []
|
|
800
|
+
for orig_group in identity_pattern:
|
|
801
|
+
step_members = tuple(
|
|
802
|
+
orig_to_local[op] for op in orig_group if op in orig_to_local
|
|
803
|
+
)
|
|
804
|
+
if len(step_members) >= 2:
|
|
805
|
+
local_groups.append(step_members)
|
|
806
|
+
if local_groups:
|
|
807
|
+
step_identity_pattern = tuple(local_groups)
|
|
808
|
+
|
|
809
|
+
step_canonical = ",".join(step_input_parts) + "->" + step_output
|
|
810
|
+
|
|
811
|
+
# Route per-step binary calls through the shared LRU cache so that
|
|
812
|
+
# identical sub-steps across different top-level expressions hit once.
|
|
813
|
+
step_cost = get_accumulation_cost_cached(
|
|
814
|
+
canonical_subscripts=step_canonical,
|
|
815
|
+
input_parts=tuple(step_input_parts),
|
|
816
|
+
output_subscript=step_output,
|
|
817
|
+
shapes=tuple(tuple(s) for s in step_shapes),
|
|
818
|
+
sym_fingerprint=step_sym_fp,
|
|
819
|
+
identity_pattern=step_identity_pattern,
|
|
820
|
+
partition_budget=partition_budget,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# Sprint 4: candidate-list cost selection.
|
|
824
|
+
# Compute all valid Burnside-based candidates and pick the min.
|
|
825
|
+
#
|
|
826
|
+
# - Cat A (per-input wreath/sigma): always valid, baseline (step_cost).
|
|
827
|
+
# - Cat B (output-orbit Burnside): valid when merged_output_group is
|
|
828
|
+
# non-trivial; formula ``O_out · (2·W − 1)``. Previously gated to
|
|
829
|
+
# fire only when Cat A found no savings; now competes unconditionally.
|
|
830
|
+
# - Cat C (joint-Burnside on V ∪ W): valid in Regime 1 with V preserved
|
|
831
|
+
# setwise; via compute_step_cost_from_joint_group().
|
|
832
|
+
#
|
|
833
|
+
# The winning category is threaded into AccumulationCost.cost_source.
|
|
834
|
+
candidates: list[tuple[AccumulationCost, str]] = [(step_cost, "per-input")]
|
|
835
|
+
|
|
836
|
+
if oracle is not None:
|
|
837
|
+
merged_subset_local = frozenset().union(*step_input_subsets)
|
|
838
|
+
ss_merged = oracle.sym(merged_subset_local)
|
|
839
|
+
|
|
840
|
+
# Step-local size dict (used by both Cat B and Cat C).
|
|
841
|
+
step_size_dict: dict[str, int] = {}
|
|
842
|
+
for sub, shape in zip(step_input_parts, step_shapes, strict=False):
|
|
843
|
+
for char, sz in zip(sub, shape, strict=False):
|
|
844
|
+
step_size_dict[char] = sz
|
|
845
|
+
|
|
846
|
+
# --- Cat C candidate: joint group ---
|
|
847
|
+
joint_group = ss_merged.joint
|
|
848
|
+
# Sprint 3 (#55): project joint group to surviving labels when
|
|
849
|
+
# pre-reduction removed labels from this step's subscripts.
|
|
850
|
+
removed_labels_in_step = set(left_only_summed) | set(right_only_summed)
|
|
851
|
+
if joint_group is not None and removed_labels_in_step:
|
|
852
|
+
joint_labels = joint_group._labels
|
|
853
|
+
if joint_labels is not None:
|
|
854
|
+
removed_positions = {
|
|
855
|
+
i
|
|
856
|
+
for i, lbl in enumerate(joint_labels)
|
|
857
|
+
if lbl in removed_labels_in_step
|
|
858
|
+
}
|
|
859
|
+
if removed_positions:
|
|
860
|
+
stabilized = joint_group.setwise_stabilizer(removed_positions)
|
|
861
|
+
surviving_positions = tuple(
|
|
862
|
+
i
|
|
863
|
+
for i in range(len(joint_labels))
|
|
864
|
+
if i not in removed_positions
|
|
865
|
+
)
|
|
866
|
+
joint_group = stabilized.restrict(surviving_positions)
|
|
867
|
+
if joint_group is not None:
|
|
868
|
+
# restrict() may drop _labels; restore them so the
|
|
869
|
+
# downstream helper can match them to the step's
|
|
870
|
+
# effective V/W labels.
|
|
871
|
+
new_labels = tuple(
|
|
872
|
+
joint_labels[i] for i in surviving_positions
|
|
873
|
+
)
|
|
874
|
+
if joint_group._labels is None:
|
|
875
|
+
joint_group._labels = new_labels
|
|
876
|
+
if joint_group.order() <= 1:
|
|
877
|
+
joint_group = None
|
|
878
|
+
if joint_group is not None and joint_group.generators:
|
|
879
|
+
# V/W label tuples in canonical order (V first, then W).
|
|
880
|
+
v_labels_tup = tuple(step_output)
|
|
881
|
+
seen_w: set[str] = set()
|
|
882
|
+
w_labels_tup = tuple(
|
|
883
|
+
c
|
|
884
|
+
for sub in step_input_parts
|
|
885
|
+
for c in sub
|
|
886
|
+
if c not in v_labels_tup and not (c in seen_w or seen_w.add(c))
|
|
887
|
+
)
|
|
888
|
+
from flopscope._config import get_setting
|
|
889
|
+
|
|
890
|
+
joint_total = compute_step_cost_from_joint_group(
|
|
891
|
+
joint_group=joint_group,
|
|
892
|
+
v_labels=v_labels_tup,
|
|
893
|
+
w_labels=w_labels_tup,
|
|
894
|
+
sizes=step_size_dict,
|
|
895
|
+
num_terms=len(step_input_parts),
|
|
896
|
+
dimino_budget=int(get_setting("dimino_budget")), # type: ignore[arg-type]
|
|
897
|
+
)
|
|
898
|
+
if joint_total is not None:
|
|
899
|
+
joint_cost = AccumulationCost(
|
|
900
|
+
total=joint_total,
|
|
901
|
+
mu=step_cost.mu,
|
|
902
|
+
alpha=step_cost.alpha,
|
|
903
|
+
m_total=step_cost.m_total,
|
|
904
|
+
dense_baseline=step_cost.dense_baseline,
|
|
905
|
+
num_terms=step_cost.num_terms,
|
|
906
|
+
per_component=step_cost.per_component,
|
|
907
|
+
fallback_used=step_cost.fallback_used,
|
|
908
|
+
unavailable_components=step_cost.unavailable_components,
|
|
909
|
+
unavailable_reason=step_cost.unavailable_reason,
|
|
910
|
+
per_step=step_cost.per_step,
|
|
911
|
+
path=step_cost.path,
|
|
912
|
+
)
|
|
913
|
+
candidates.append((joint_cost, "joint-burnside"))
|
|
914
|
+
|
|
915
|
+
# --- Cat B candidate: output-orbit Burnside (UNCONDITIONAL) ---
|
|
916
|
+
# Previously fallback-only (fires only when Cat A == dense FMA);
|
|
917
|
+
# now competes with Cat A and Cat C in the candidate list.
|
|
918
|
+
step_all_chars = set("".join(step_input_parts))
|
|
919
|
+
step_dense_baseline_val = math.prod(
|
|
920
|
+
step_size_dict.get(c, 1) for c in step_all_chars
|
|
921
|
+
)
|
|
922
|
+
step_output_dense = math.prod(step_size_dict.get(c, 1) for c in step_output)
|
|
923
|
+
merged_output_grp = ss_merged.output if ss_merged else None
|
|
924
|
+
if merged_output_grp is not None and merged_output_grp.generators:
|
|
925
|
+
from flopscope._perm_group import _dimino
|
|
926
|
+
from flopscope._perm_group import _PermutationCompat as _Perm
|
|
927
|
+
|
|
928
|
+
labels = merged_output_grp._labels
|
|
929
|
+
if labels is not None:
|
|
930
|
+
char_to_axis = {c: i for i, c in enumerate(step_output)}
|
|
931
|
+
label_to_lpos = {lbl: k for k, lbl in enumerate(labels)}
|
|
932
|
+
axis_gens: list[_Perm] = []
|
|
933
|
+
for gen in merged_output_grp.generators:
|
|
934
|
+
arr = list(gen.array_form)
|
|
935
|
+
axis_gen = list(range(len(step_output)))
|
|
936
|
+
for ax, char in enumerate(step_output):
|
|
937
|
+
if char in label_to_lpos:
|
|
938
|
+
lpos = label_to_lpos[char]
|
|
939
|
+
tgt = labels[arr[lpos]]
|
|
940
|
+
if tgt in char_to_axis:
|
|
941
|
+
axis_gen[ax] = char_to_axis[tgt]
|
|
942
|
+
axis_gens.append(_Perm(axis_gen))
|
|
943
|
+
else:
|
|
944
|
+
axis_gens = list(merged_output_grp.generators)
|
|
945
|
+
|
|
946
|
+
try:
|
|
947
|
+
all_elems = _dimino(tuple(axis_gens))
|
|
948
|
+
output_sizes = tuple(step_size_dict.get(c, 1) for c in step_output)
|
|
949
|
+
corrected_orbits = size_aware_burnside(all_elems, output_sizes)
|
|
950
|
+
if 0 < corrected_orbits < step_output_dense:
|
|
951
|
+
step_w_chars = step_all_chars - set(step_output)
|
|
952
|
+
step_w_size = (
|
|
953
|
+
math.prod(step_size_dict.get(c, 1) for c in step_w_chars)
|
|
954
|
+
if step_w_chars
|
|
955
|
+
else 1
|
|
956
|
+
)
|
|
957
|
+
corrected_total = corrected_orbits * (2 * step_w_size - 1)
|
|
958
|
+
if corrected_total > 0:
|
|
959
|
+
cat_b_cost = AccumulationCost(
|
|
960
|
+
total=corrected_total,
|
|
961
|
+
mu=step_w_size * corrected_orbits - corrected_orbits,
|
|
962
|
+
alpha=step_w_size * corrected_orbits,
|
|
963
|
+
m_total=step_w_size * corrected_orbits,
|
|
964
|
+
dense_baseline=step_dense_baseline_val,
|
|
965
|
+
num_terms=step_cost.num_terms,
|
|
966
|
+
per_component=step_cost.per_component,
|
|
967
|
+
fallback_used=step_cost.fallback_used,
|
|
968
|
+
unavailable_components=step_cost.unavailable_components,
|
|
969
|
+
unavailable_reason=step_cost.unavailable_reason,
|
|
970
|
+
per_step=step_cost.per_step,
|
|
971
|
+
path=step_cost.path,
|
|
972
|
+
)
|
|
973
|
+
candidates.append((cat_b_cost, "output-burnside"))
|
|
974
|
+
except Exception:
|
|
975
|
+
pass # dimino budget exceeded or other; skip Cat B
|
|
976
|
+
|
|
977
|
+
# Pick the tightest candidate.
|
|
978
|
+
step_cost, cost_source_chosen = min(candidates, key=lambda x: x[0].total)
|
|
979
|
+
|
|
980
|
+
# Stamp cost_source onto the winning AccumulationCost (frozen
|
|
981
|
+
# dataclass → use dataclasses.replace).
|
|
982
|
+
step_cost = _dc_replace(step_cost, cost_source=cost_source_chosen)
|
|
983
|
+
|
|
984
|
+
# Sprint 3: step total = pre_reductions + residual contraction.
|
|
985
|
+
# Runs AFTER candidate selection so the chosen cost_source is preserved.
|
|
986
|
+
if pre_reduce_cost_total > 0:
|
|
987
|
+
step_cost = _dc_replace(
|
|
988
|
+
step_cost, total=step_cost.total + pre_reduce_cost_total
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
per_step_costs.append(step_cost)
|
|
992
|
+
pre_reductions_per_step.append(tuple(pre_reductions_for_step))
|
|
993
|
+
|
|
994
|
+
# Update current_subsets: remove contracted inputs (highest index first
|
|
995
|
+
# to preserve lower indices), then append the merged output subset.
|
|
996
|
+
merged_subset: frozenset[int] = frozenset().union(*step_input_subsets)
|
|
997
|
+
for pos in contract_positions:
|
|
998
|
+
current_subsets.pop(pos)
|
|
999
|
+
current_subsets.append(merged_subset)
|
|
1000
|
+
|
|
1001
|
+
total = sum(s.total for s in per_step_costs)
|
|
1002
|
+
mu_total = sum((s.mu or 0) for s in per_step_costs)
|
|
1003
|
+
alpha_total = sum((s.alpha or 0) for s in per_step_costs)
|
|
1004
|
+
# m_total for the path-level result must reflect the number of unique output
|
|
1005
|
+
# elements in the FULL k-ary expression, not the product of per-step intermediate
|
|
1006
|
+
# m_total values (which would multiply intermediate tensor sizes together and
|
|
1007
|
+
# always exceed dense_baseline, making _has_savings() return False).
|
|
1008
|
+
# full_expression_component_costs holds the full-expression per_component data
|
|
1009
|
+
# computed by the wreath/sigma pass before the path decomposition.
|
|
1010
|
+
if full_expression_component_costs:
|
|
1011
|
+
m_total = math.prod(c.m for c in full_expression_component_costs)
|
|
1012
|
+
else:
|
|
1013
|
+
# Fallback: sum of per-step m_total values is still wrong, but if we have
|
|
1014
|
+
# no component data just use dense_baseline as a conservative estimate.
|
|
1015
|
+
m_total = dense_baseline
|
|
1016
|
+
fallback_used = any(s.fallback_used for s in per_step_costs)
|
|
1017
|
+
unavailable_components: tuple[int, ...] = tuple(
|
|
1018
|
+
i for i, s in enumerate(per_step_costs) if s.fallback_used
|
|
1019
|
+
)
|
|
1020
|
+
unavailable_reason = next(
|
|
1021
|
+
(s.unavailable_reason for s in per_step_costs if s.unavailable_reason),
|
|
1022
|
+
None,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
return AccumulationCost(
|
|
1026
|
+
total=total,
|
|
1027
|
+
mu=mu_total if mu_total > 0 else None,
|
|
1028
|
+
alpha=alpha_total if alpha_total > 0 else None,
|
|
1029
|
+
m_total=m_total,
|
|
1030
|
+
dense_baseline=dense_baseline,
|
|
1031
|
+
num_terms=num_ops,
|
|
1032
|
+
per_component=full_expression_component_costs or (),
|
|
1033
|
+
fallback_used=fallback_used,
|
|
1034
|
+
unavailable_components=unavailable_components,
|
|
1035
|
+
unavailable_reason=unavailable_reason,
|
|
1036
|
+
per_step=tuple(per_step_costs),
|
|
1037
|
+
path=tuple(tuple(p) for p in path_info.path),
|
|
1038
|
+
pre_reductions_per_step=tuple(pre_reductions_per_step),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def compute_accumulation_cost(
|
|
1043
|
+
*,
|
|
1044
|
+
canonical_subscripts: str,
|
|
1045
|
+
input_parts: _Seq[str],
|
|
1046
|
+
output_subscript: str,
|
|
1047
|
+
shapes: _Seq[_Seq[int]],
|
|
1048
|
+
per_op_symmetries: _Seq[_Any] | None,
|
|
1049
|
+
identity_pattern: tuple[tuple[int, ...], ...] | None,
|
|
1050
|
+
partition_budget: int | None = None,
|
|
1051
|
+
) -> AccumulationCost:
|
|
1052
|
+
"""End-to-end whole-expression cost.
|
|
1053
|
+
|
|
1054
|
+
Inputs:
|
|
1055
|
+
canonical_subscripts: the einsum string after canonicalization
|
|
1056
|
+
(e.g. 'ij,jk->ik').
|
|
1057
|
+
input_parts: per-operand subscript strings.
|
|
1058
|
+
output_subscript: output labels (may be empty).
|
|
1059
|
+
shapes: per-operand shape tuples.
|
|
1060
|
+
per_op_symmetries: parallel to operands; SymmetryGroup objects, strings
|
|
1061
|
+
('symmetric', 'cyclic', 'dihedral'), or None.
|
|
1062
|
+
identity_pattern: tuple of operand-position tuples that share id.
|
|
1063
|
+
partition_budget: per-component partition cap; defaults to global setting.
|
|
1064
|
+
|
|
1065
|
+
Dimino-budget fallback: any internal call to ``_dimino`` that exceeds the
|
|
1066
|
+
configured ``dimino_budget`` (default 500_000) raises
|
|
1067
|
+
:class:`_DiminoBudgetExceeded`. This function catches the exception,
|
|
1068
|
+
emits a :class:`CostFallbackWarning`, and returns an
|
|
1069
|
+
:class:`AccumulationCost` with ``fallback_used=True`` and
|
|
1070
|
+
``total = k * dense_baseline`` (the no-symmetry direct-event count) —
|
|
1071
|
+
the same shape the partition-budget fallback uses.
|
|
1072
|
+
"""
|
|
1073
|
+
from flopscope._config import get_setting
|
|
1074
|
+
from flopscope._perm_group import _DiminoBudgetExceeded
|
|
1075
|
+
|
|
1076
|
+
num_ops = len(input_parts)
|
|
1077
|
+
if per_op_symmetries is None:
|
|
1078
|
+
per_op_symmetries = (None,) * num_ops
|
|
1079
|
+
if partition_budget is None:
|
|
1080
|
+
partition_budget = _cast(int, get_setting("partition_budget"))
|
|
1081
|
+
|
|
1082
|
+
operand_names = _operand_names_from_identity_pattern(num_ops, identity_pattern)
|
|
1083
|
+
|
|
1084
|
+
graph = build_bipartite(
|
|
1085
|
+
subscripts=tuple(input_parts),
|
|
1086
|
+
output=output_subscript,
|
|
1087
|
+
operand_names=operand_names,
|
|
1088
|
+
)
|
|
1089
|
+
matrix_data = build_incidence_matrix(graph)
|
|
1090
|
+
|
|
1091
|
+
# Build per-operand wreath inputs.
|
|
1092
|
+
axis_ranks = tuple(len(part) for part in input_parts)
|
|
1093
|
+
u_offsets = tuple(sum(axis_ranks[:i]) for i in range(num_ops))
|
|
1094
|
+
grouped_ops: set[int] = set()
|
|
1095
|
+
for grp in graph.identical_groups:
|
|
1096
|
+
grouped_ops.update(grp)
|
|
1097
|
+
singleton_groups = [(i,) for i in range(num_ops) if i not in grouped_ops]
|
|
1098
|
+
identical_groups_all = (*graph.identical_groups, *singleton_groups)
|
|
1099
|
+
|
|
1100
|
+
# Pre-compute sizes / dense_baseline up-front so the bail-handler can use
|
|
1101
|
+
# them without redoing graph work.
|
|
1102
|
+
size_map = _build_size_map(input_parts, shapes)
|
|
1103
|
+
sizes = tuple(size_map[lbl] for lbl in graph.all_labels)
|
|
1104
|
+
dense_baseline = math.prod(sizes) if sizes else 1
|
|
1105
|
+
|
|
1106
|
+
try:
|
|
1107
|
+
wreath_elements = list(
|
|
1108
|
+
enumerate_wreath(
|
|
1109
|
+
identical_groups=identical_groups_all,
|
|
1110
|
+
per_op_symmetry=tuple(
|
|
1111
|
+
_per_op_symmetry_for_wreath(s) for s in per_op_symmetries
|
|
1112
|
+
),
|
|
1113
|
+
axis_ranks=axis_ranks,
|
|
1114
|
+
u_offsets=u_offsets,
|
|
1115
|
+
)
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
sigma_results = run_sigma_loop(graph, matrix_data, tuple(wreath_elements))
|
|
1119
|
+
detected = build_full_group(sigma_results, all_labels=graph.all_labels)
|
|
1120
|
+
|
|
1121
|
+
# Decompose into components.
|
|
1122
|
+
components = decompose_into_components(
|
|
1123
|
+
detected_group=detected,
|
|
1124
|
+
v_labels=graph.free_labels,
|
|
1125
|
+
w_labels=graph.summed_labels,
|
|
1126
|
+
sizes=sizes,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
component_costs = run_ladder_per_component(
|
|
1130
|
+
components,
|
|
1131
|
+
partition_budget=partition_budget,
|
|
1132
|
+
)
|
|
1133
|
+
except _DiminoBudgetExceeded as exc:
|
|
1134
|
+
fallback_total = num_ops * dense_baseline
|
|
1135
|
+
reason = (
|
|
1136
|
+
f"dimino_budget exceeded ({exc.seen_count} > {exc.budget}); "
|
|
1137
|
+
f"auto-inferred symmetry group is too large to enumerate exactly"
|
|
1138
|
+
)
|
|
1139
|
+
warnings.warn(
|
|
1140
|
+
CostFallbackWarning(
|
|
1141
|
+
f"accumulation: {reason} — charging dense cost {fallback_total} "
|
|
1142
|
+
f"= {num_ops} × {dense_baseline}. Raise via "
|
|
1143
|
+
f"flopscope.configure(dimino_budget=...) to attempt exact counting."
|
|
1144
|
+
),
|
|
1145
|
+
stacklevel=4,
|
|
1146
|
+
)
|
|
1147
|
+
return AccumulationCost(
|
|
1148
|
+
total=fallback_total,
|
|
1149
|
+
mu=None,
|
|
1150
|
+
alpha=None,
|
|
1151
|
+
m_total=1,
|
|
1152
|
+
dense_baseline=dense_baseline,
|
|
1153
|
+
num_terms=num_ops,
|
|
1154
|
+
per_component=(),
|
|
1155
|
+
fallback_used=True,
|
|
1156
|
+
unavailable_components=(),
|
|
1157
|
+
unavailable_reason=reason,
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
if num_ops >= 3:
|
|
1161
|
+
return _walk_path_and_aggregate(
|
|
1162
|
+
canonical_subscripts=canonical_subscripts,
|
|
1163
|
+
input_parts=input_parts,
|
|
1164
|
+
output_subscript=output_subscript,
|
|
1165
|
+
shapes=shapes,
|
|
1166
|
+
per_op_symmetries=per_op_symmetries,
|
|
1167
|
+
identity_pattern=identity_pattern,
|
|
1168
|
+
partition_budget=partition_budget,
|
|
1169
|
+
dense_baseline=dense_baseline,
|
|
1170
|
+
full_expression_component_costs=component_costs,
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
# ── Sprint 3 (#55): 2-op case — pre-reduce isolated summed labels ────────
|
|
1174
|
+
# For binary (2-operand) einsums, detect isolated summed labels, compute
|
|
1175
|
+
# pre-reduction costs, then re-evaluate the RESIDUAL contraction on the
|
|
1176
|
+
# reduced subscripts. Total = pre_reduction_costs + residual_cost.
|
|
1177
|
+
if num_ops == 2:
|
|
1178
|
+
from flopscope._accumulation._public import (
|
|
1179
|
+
_per_op_sym_fingerprint,
|
|
1180
|
+
reduction_accumulation_cost,
|
|
1181
|
+
)
|
|
1182
|
+
from flopscope._symmetry_utils import reduce_group
|
|
1183
|
+
|
|
1184
|
+
input_parts_list = list(input_parts)
|
|
1185
|
+
shapes_list = [tuple(s) for s in shapes]
|
|
1186
|
+
|
|
1187
|
+
summed_set = set("".join(input_parts_list)) - set(output_subscript)
|
|
1188
|
+
left_labels_set = set(input_parts_list[0])
|
|
1189
|
+
right_labels_set = set(input_parts_list[1])
|
|
1190
|
+
left_only_summed = (left_labels_set & summed_set) - right_labels_set
|
|
1191
|
+
right_only_summed = (right_labels_set & summed_set) - left_labels_set
|
|
1192
|
+
|
|
1193
|
+
pre_reductions_for_step: list[PreReduction] = []
|
|
1194
|
+
pre_reduce_cost_total = 0
|
|
1195
|
+
|
|
1196
|
+
left_sym = per_op_symmetries[0] if per_op_symmetries else None
|
|
1197
|
+
right_sym = per_op_symmetries[1] if per_op_symmetries else None
|
|
1198
|
+
|
|
1199
|
+
# Default effective values (no-op if no pre-reduction).
|
|
1200
|
+
eff_left_part = input_parts_list[0]
|
|
1201
|
+
eff_right_part = input_parts_list[1]
|
|
1202
|
+
eff_left_shape = shapes_list[0]
|
|
1203
|
+
eff_right_shape = shapes_list[1]
|
|
1204
|
+
eff_left_sym = left_sym
|
|
1205
|
+
eff_right_sym = right_sym
|
|
1206
|
+
|
|
1207
|
+
# Pre-reduce left operand
|
|
1208
|
+
if left_only_summed:
|
|
1209
|
+
reduce_axes = tuple(
|
|
1210
|
+
i for i, c in enumerate(input_parts_list[0]) if c in left_only_summed
|
|
1211
|
+
)
|
|
1212
|
+
left_proxy = _build_symmetric_proxy(shapes_list[0], left_sym)
|
|
1213
|
+
reduce_cost = reduction_accumulation_cost(
|
|
1214
|
+
left_proxy, axis=reduce_axes
|
|
1215
|
+
).total
|
|
1216
|
+
pre_reduce_cost_total += reduce_cost
|
|
1217
|
+
|
|
1218
|
+
surviving_subscript = "".join(
|
|
1219
|
+
c for c in input_parts_list[0] if c not in left_only_summed
|
|
1220
|
+
)
|
|
1221
|
+
surviving_shape = tuple(
|
|
1222
|
+
s for i, s in enumerate(shapes_list[0]) if i not in set(reduce_axes)
|
|
1223
|
+
)
|
|
1224
|
+
surviving_sym = reduce_group(
|
|
1225
|
+
left_sym,
|
|
1226
|
+
ndim=len(input_parts_list[0]),
|
|
1227
|
+
axis=reduce_axes,
|
|
1228
|
+
)
|
|
1229
|
+
eff_left_part = surviving_subscript
|
|
1230
|
+
eff_left_shape = surviving_shape
|
|
1231
|
+
eff_left_sym = surviving_sym
|
|
1232
|
+
pre_reductions_for_step.append(
|
|
1233
|
+
PreReduction(
|
|
1234
|
+
operand_index=0,
|
|
1235
|
+
removed_labels=tuple(sorted(left_only_summed)),
|
|
1236
|
+
cost=reduce_cost,
|
|
1237
|
+
surviving_subscript=surviving_subscript,
|
|
1238
|
+
reduced_symmetry_fingerprint=(
|
|
1239
|
+
_per_op_sym_fingerprint(surviving_sym)
|
|
1240
|
+
if surviving_sym is not None
|
|
1241
|
+
else None
|
|
1242
|
+
),
|
|
1243
|
+
)
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
# Pre-reduce right operand
|
|
1247
|
+
if right_only_summed:
|
|
1248
|
+
reduce_axes = tuple(
|
|
1249
|
+
i for i, c in enumerate(input_parts_list[1]) if c in right_only_summed
|
|
1250
|
+
)
|
|
1251
|
+
right_proxy = _build_symmetric_proxy(shapes_list[1], right_sym)
|
|
1252
|
+
reduce_cost = reduction_accumulation_cost(
|
|
1253
|
+
right_proxy, axis=reduce_axes
|
|
1254
|
+
).total
|
|
1255
|
+
pre_reduce_cost_total += reduce_cost
|
|
1256
|
+
|
|
1257
|
+
surviving_subscript = "".join(
|
|
1258
|
+
c for c in input_parts_list[1] if c not in right_only_summed
|
|
1259
|
+
)
|
|
1260
|
+
surviving_shape = tuple(
|
|
1261
|
+
s for i, s in enumerate(shapes_list[1]) if i not in set(reduce_axes)
|
|
1262
|
+
)
|
|
1263
|
+
surviving_sym = reduce_group(
|
|
1264
|
+
right_sym,
|
|
1265
|
+
ndim=len(input_parts_list[1]),
|
|
1266
|
+
axis=reduce_axes,
|
|
1267
|
+
)
|
|
1268
|
+
eff_right_part = surviving_subscript
|
|
1269
|
+
eff_right_shape = surviving_shape
|
|
1270
|
+
eff_right_sym = surviving_sym
|
|
1271
|
+
pre_reductions_for_step.append(
|
|
1272
|
+
PreReduction(
|
|
1273
|
+
operand_index=1,
|
|
1274
|
+
removed_labels=tuple(sorted(right_only_summed)),
|
|
1275
|
+
cost=reduce_cost,
|
|
1276
|
+
surviving_subscript=surviving_subscript,
|
|
1277
|
+
reduced_symmetry_fingerprint=(
|
|
1278
|
+
_per_op_sym_fingerprint(surviving_sym)
|
|
1279
|
+
if surviving_sym is not None
|
|
1280
|
+
else None
|
|
1281
|
+
),
|
|
1282
|
+
)
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
if pre_reductions_for_step:
|
|
1286
|
+
# Compute residual cost on the REDUCED subscripts/shapes/symmetries.
|
|
1287
|
+
from ._cache import get_accumulation_cost_cached
|
|
1288
|
+
|
|
1289
|
+
eff_canonical = f"{eff_left_part},{eff_right_part}->{output_subscript}"
|
|
1290
|
+
eff_sym_fp = (
|
|
1291
|
+
_per_op_sym_fingerprint(eff_left_sym)
|
|
1292
|
+
if eff_left_sym is not None
|
|
1293
|
+
else None,
|
|
1294
|
+
_per_op_sym_fingerprint(eff_right_sym)
|
|
1295
|
+
if eff_right_sym is not None
|
|
1296
|
+
else None,
|
|
1297
|
+
)
|
|
1298
|
+
residual_cost = get_accumulation_cost_cached(
|
|
1299
|
+
canonical_subscripts=eff_canonical,
|
|
1300
|
+
input_parts=(eff_left_part, eff_right_part),
|
|
1301
|
+
output_subscript=output_subscript,
|
|
1302
|
+
shapes=(eff_left_shape, eff_right_shape),
|
|
1303
|
+
sym_fingerprint=eff_sym_fp,
|
|
1304
|
+
identity_pattern=identity_pattern,
|
|
1305
|
+
partition_budget=partition_budget,
|
|
1306
|
+
)
|
|
1307
|
+
return AccumulationCost(
|
|
1308
|
+
total=residual_cost.total + pre_reduce_cost_total,
|
|
1309
|
+
mu=residual_cost.mu,
|
|
1310
|
+
alpha=residual_cost.alpha,
|
|
1311
|
+
m_total=residual_cost.m_total,
|
|
1312
|
+
dense_baseline=dense_baseline,
|
|
1313
|
+
num_terms=residual_cost.num_terms,
|
|
1314
|
+
per_component=component_costs,
|
|
1315
|
+
fallback_used=residual_cost.fallback_used,
|
|
1316
|
+
unavailable_components=residual_cost.unavailable_components,
|
|
1317
|
+
unavailable_reason=residual_cost.unavailable_reason,
|
|
1318
|
+
per_step=residual_cost.per_step,
|
|
1319
|
+
path=residual_cost.path,
|
|
1320
|
+
pre_reductions_per_step=(tuple(pre_reductions_for_step),),
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
return aggregate_einsum(
|
|
1324
|
+
component_costs=component_costs,
|
|
1325
|
+
num_terms=num_ops,
|
|
1326
|
+
dense_baseline=dense_baseline,
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
# ── Reduction-cost API hook (designed for, not implemented) ──────────
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def aggregate_reduction(
|
|
1334
|
+
component_costs: Sequence[ComponentCost],
|
|
1335
|
+
*,
|
|
1336
|
+
op_factor: int = 1,
|
|
1337
|
+
dense_baseline: int,
|
|
1338
|
+
output_dense: int,
|
|
1339
|
+
extra_ops: int = 0,
|
|
1340
|
+
) -> AccumulationCost:
|
|
1341
|
+
"""Aggregate per-component costs into a ufunc.reduce cost.
|
|
1342
|
+
|
|
1343
|
+
Formula:
|
|
1344
|
+
total = op_factor × (∏α_c - output_dense) + extra_ops
|
|
1345
|
+
|
|
1346
|
+
``output_dense`` is the **orbit-aware** output count: the orchestrator
|
|
1347
|
+
passes ``_num_output_orbits(input_shape, axes_summed, symmetry)`` through
|
|
1348
|
+
this slot. For dense inputs this equals ``prod(output_shape)`` so the
|
|
1349
|
+
name is consistent with the locked-stub docstring.
|
|
1350
|
+
|
|
1351
|
+
Fallback (any component unavailable):
|
|
1352
|
+
total = output_dense × (input_axis_size - 1) × op_factor + extra_ops
|
|
1353
|
+
where input_axis_size = dense_baseline / output_dense.
|
|
1354
|
+
|
|
1355
|
+
See .aicrowd/superpowers/specs/2026-05-13-symmetry-aware-reduction-cost-design.md.
|
|
1356
|
+
"""
|
|
1357
|
+
failing = [i for i, c in enumerate(component_costs) if c.alpha is None]
|
|
1358
|
+
|
|
1359
|
+
m_total = 1
|
|
1360
|
+
for c in component_costs:
|
|
1361
|
+
m_total *= c.m
|
|
1362
|
+
|
|
1363
|
+
if failing:
|
|
1364
|
+
# Floor-division is safe: the orchestrator passes
|
|
1365
|
+
# dense_baseline = prod(input_shape) and output_dense = num_output_orbits,
|
|
1366
|
+
# which evenly divides for symmetry=None and represents the orbit-discounted
|
|
1367
|
+
# input-axis size for symmetric inputs.
|
|
1368
|
+
input_axis_size = dense_baseline // output_dense if output_dense else 0
|
|
1369
|
+
fallback_total = (
|
|
1370
|
+
output_dense * max(0, input_axis_size - 1) * op_factor + extra_ops
|
|
1371
|
+
)
|
|
1372
|
+
first = component_costs[failing[0]]
|
|
1373
|
+
reason = first.unavailable_reason or "partition_budget exceeded"
|
|
1374
|
+
labels = ", ".join(first.labels)
|
|
1375
|
+
warnings.warn(
|
|
1376
|
+
CostFallbackWarning(
|
|
1377
|
+
f"reduction: component {list(failing)} ({labels}) returned "
|
|
1378
|
+
f"unavailable — charging dense cost {fallback_total}. "
|
|
1379
|
+
f"Failing reason: {reason}."
|
|
1380
|
+
),
|
|
1381
|
+
stacklevel=4,
|
|
1382
|
+
)
|
|
1383
|
+
return AccumulationCost(
|
|
1384
|
+
total=fallback_total,
|
|
1385
|
+
mu=None,
|
|
1386
|
+
alpha=None,
|
|
1387
|
+
m_total=m_total,
|
|
1388
|
+
dense_baseline=dense_baseline,
|
|
1389
|
+
num_terms=1,
|
|
1390
|
+
per_component=tuple(component_costs),
|
|
1391
|
+
fallback_used=True,
|
|
1392
|
+
unavailable_components=tuple(failing),
|
|
1393
|
+
unavailable_reason=reason,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
alpha_product = 1
|
|
1397
|
+
for c in component_costs:
|
|
1398
|
+
assert c.alpha is not None # for type narrowing
|
|
1399
|
+
alpha_product *= c.alpha
|
|
1400
|
+
|
|
1401
|
+
# Invariant: output_dense (num_output_orbits) <= alpha_product. The
|
|
1402
|
+
# orchestrator computes both consistently. If you ever see a negative
|
|
1403
|
+
# total here, the orchestrator passed an inconsistent output_dense.
|
|
1404
|
+
total = op_factor * (alpha_product - output_dense) + extra_ops
|
|
1405
|
+
return AccumulationCost(
|
|
1406
|
+
total=total,
|
|
1407
|
+
mu=0, # k=1 single-operand reduction
|
|
1408
|
+
alpha=alpha_product,
|
|
1409
|
+
m_total=m_total,
|
|
1410
|
+
dense_baseline=dense_baseline,
|
|
1411
|
+
num_terms=1,
|
|
1412
|
+
per_component=tuple(component_costs),
|
|
1413
|
+
fallback_used=False,
|
|
1414
|
+
)
|