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,1478 @@
|
|
|
1
|
+
"""Contains PathInfo, StepInfo, and build_path_info (stripped from opt_einsum contract.py).
|
|
2
|
+
|
|
3
|
+
Excluded: contract, _core_contract, ContractExpression, _einsum, _tensordot,
|
|
4
|
+
_transpose, backends/sharing imports, _filter_einsum_defaults,
|
|
5
|
+
format_const_einsum_str, shape_only.
|
|
6
|
+
|
|
7
|
+
The local contract_path() body was removed in Task 7+8; upstream opt_einsum is
|
|
8
|
+
used directly via __init__.py's wrapper.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from hashlib import sha1
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from flopscope._perm_group import SymmetryGroup
|
|
18
|
+
|
|
19
|
+
from . import _helpers as helpers
|
|
20
|
+
from ._hsluv import rgb_distance_hex, rich_label_palette
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"build_path_info",
|
|
24
|
+
"PathInfo",
|
|
25
|
+
"PreReduction",
|
|
26
|
+
"StepInfo",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_RICH_SYMMETRY_STYLES = {
|
|
30
|
+
"S": "bold bright_cyan",
|
|
31
|
+
"C": "bold bright_magenta",
|
|
32
|
+
"D": "bold bright_yellow",
|
|
33
|
+
"W": "bold bright_green",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PreReduction:
|
|
39
|
+
"""Metadata for a single pre-reduction performed on a binary step's input.
|
|
40
|
+
|
|
41
|
+
When a label is summed in this step AND appears in only ONE of the two
|
|
42
|
+
inputs, that input is pre-reduced along the isolated axis BEFORE the
|
|
43
|
+
main contraction. This mirrors PyTorch's `sumproduct_pair` optimization
|
|
44
|
+
(`aten/src/ATen/native/Linear.cpp`).
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
operand_index: 0 or 1 — which input of the binary step (left or right).
|
|
48
|
+
removed_labels: labels dropped from this input's subscript (sorted tuple).
|
|
49
|
+
cost: flops charged by this pre-reduction (from reduction_accumulation_cost).
|
|
50
|
+
surviving_subscript: this input's subscript after dropping removed_labels.
|
|
51
|
+
reduced_symmetry_fingerprint: canonical sym fingerprint of the post-
|
|
52
|
+
reduction operand symmetry; fed to the residual contraction's
|
|
53
|
+
per-input fingerprint. None when the operand had no declared
|
|
54
|
+
symmetry or the reduction killed all surviving symmetry.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
operand_index: int
|
|
58
|
+
removed_labels: tuple[str, ...]
|
|
59
|
+
cost: int
|
|
60
|
+
surviving_subscript: str
|
|
61
|
+
reduced_symmetry_fingerprint: tuple | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class StepInfo:
|
|
66
|
+
"""Per-step diagnostics for a contraction path."""
|
|
67
|
+
|
|
68
|
+
subscript: str
|
|
69
|
+
"""Einsum subscript for this step, e.g. ``"ijk,ai->ajk"``."""
|
|
70
|
+
|
|
71
|
+
flop_cost: int
|
|
72
|
+
"""FLOP cost (FMA = 1 op)."""
|
|
73
|
+
|
|
74
|
+
input_shapes: list[tuple[int, ...]]
|
|
75
|
+
"""Shapes of the input operands for this step."""
|
|
76
|
+
|
|
77
|
+
output_shape: tuple[int, ...]
|
|
78
|
+
"""Shape of the output operand for this step."""
|
|
79
|
+
|
|
80
|
+
input_groups: list = field(default_factory=list)
|
|
81
|
+
"""SymmetryGroup for each input in this step."""
|
|
82
|
+
|
|
83
|
+
output_group: object | None = None
|
|
84
|
+
"""SymmetryGroup of the output, or None."""
|
|
85
|
+
|
|
86
|
+
dense_flop_cost: int = 0
|
|
87
|
+
"""FLOP cost without symmetry (FMA = 1 op)."""
|
|
88
|
+
|
|
89
|
+
symmetry_savings: float = 0.0
|
|
90
|
+
"""Fraction saved: ``1 - (flop_cost / dense_flop_cost)``. Zero when no symmetry."""
|
|
91
|
+
|
|
92
|
+
inner_group: object | None = None
|
|
93
|
+
"""SymmetryGroup among the contracted (summed) labels, or None."""
|
|
94
|
+
|
|
95
|
+
inner_applied: bool = False
|
|
96
|
+
"""Whether inner (W-side) symmetry was actually applied at this step."""
|
|
97
|
+
|
|
98
|
+
blas_type: str | bool = False
|
|
99
|
+
"""BLAS classification for this step (e.g. 'GEMM', 'SYMM', False)."""
|
|
100
|
+
|
|
101
|
+
path_indices: tuple[int, ...] = ()
|
|
102
|
+
"""The SSA-id contraction tuple for this step (the entry from
|
|
103
|
+
``PathInfo.path[i]``). Useful for cross-referencing the table with
|
|
104
|
+
the raw path field."""
|
|
105
|
+
|
|
106
|
+
merged_subset: frozenset[int] | None = None
|
|
107
|
+
"""Subset of *original* operand positions that this step's output
|
|
108
|
+
intermediate covers. For step 0 contracting two original operands i
|
|
109
|
+
and j, this is ``frozenset({i, j})``. For later steps it's the union
|
|
110
|
+
of the subsets of all SSA inputs being contracted."""
|
|
111
|
+
|
|
112
|
+
pre_reductions: tuple[PreReduction, ...] = field(default_factory=tuple)
|
|
113
|
+
"""Pre-reductions applied to binary step inputs before the main contraction.
|
|
114
|
+
|
|
115
|
+
Each entry records a label that was summed out of exactly one input prior
|
|
116
|
+
to the contraction itself (mirroring PyTorch's ``sumproduct_pair``
|
|
117
|
+
optimization). Empty for all existing steps — populated by Task 4."""
|
|
118
|
+
|
|
119
|
+
cost_source: str | None = None
|
|
120
|
+
"""Which cost category produced ``flop_cost`` (Sprint 4).
|
|
121
|
+
|
|
122
|
+
One of ``"per-input"`` (Cat A wreath/sigma), ``"joint-burnside"`` (Cat C
|
|
123
|
+
on the merged-subset joint group), or ``"output-burnside"`` (Cat B on
|
|
124
|
+
the merged output group's orbit count). ``None`` only on stale
|
|
125
|
+
pre-Sprint-4 instances.
|
|
126
|
+
|
|
127
|
+
Surfaced by the renderer for transparency ("why this number?") and used
|
|
128
|
+
by regression tests to pin which category was selected."""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def flop_count(self) -> int:
|
|
132
|
+
"""Alias for ``flop_cost`` (adapter compatibility)."""
|
|
133
|
+
return self.flop_cost
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class PathInfo:
|
|
138
|
+
"""Information about a contraction path."""
|
|
139
|
+
|
|
140
|
+
path: list[tuple[int, ...]]
|
|
141
|
+
"""The optimized contraction path (list of index-tuples)."""
|
|
142
|
+
|
|
143
|
+
steps: list[StepInfo]
|
|
144
|
+
"""Per-step diagnostics."""
|
|
145
|
+
|
|
146
|
+
naive_cost: int
|
|
147
|
+
"""Naive (single-step) FLOP cost (FMA = 1 op)."""
|
|
148
|
+
|
|
149
|
+
optimized_cost: int
|
|
150
|
+
"""Sum of per-step costs (FMA = 1 op)."""
|
|
151
|
+
|
|
152
|
+
largest_intermediate: int
|
|
153
|
+
"""Number of elements in the largest intermediate tensor."""
|
|
154
|
+
|
|
155
|
+
speedup: float
|
|
156
|
+
"""``naive_cost / optimized_cost``."""
|
|
157
|
+
|
|
158
|
+
input_subscripts: str = ""
|
|
159
|
+
"""Comma-separated input subscripts, e.g. ``"ij,jk,kl"``."""
|
|
160
|
+
|
|
161
|
+
output_subscript: str = ""
|
|
162
|
+
"""Output subscript, e.g. ``"il"``."""
|
|
163
|
+
|
|
164
|
+
size_dict: dict[str, int] = field(default_factory=dict)
|
|
165
|
+
"""Mapping from index label to dimension size."""
|
|
166
|
+
|
|
167
|
+
optimizer_used: str = ""
|
|
168
|
+
"""Name of the path-finding function actually invoked. For ``optimize='auto'``
|
|
169
|
+
or ``'auto-hq'`` this resolves to the underlying inner choice
|
|
170
|
+
(e.g. ``'optimal'``, ``'branch_2'``, ``'dynamic_programming'``,
|
|
171
|
+
``'random_greedy_128'``) so users can tell which algorithm produced
|
|
172
|
+
the path. For explicit choices it matches the requested name. Empty
|
|
173
|
+
string for the trivial num_ops <= 2 case where no optimizer runs."""
|
|
174
|
+
|
|
175
|
+
# Legacy fields for backward-compat with opt_einsum tests
|
|
176
|
+
contraction_list: list = field(default_factory=list)
|
|
177
|
+
scale_list: list[int] = field(default_factory=list)
|
|
178
|
+
size_list: list[int] = field(default_factory=list)
|
|
179
|
+
_oe_naive_cost: int = 0
|
|
180
|
+
_oe_opt_cost: int = 0
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def opt_cost(self) -> Decimal:
|
|
184
|
+
"""Legacy: opt_einsum-style cost (FMA = 1 op)."""
|
|
185
|
+
return Decimal(self._oe_opt_cost)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def eq(self) -> str:
|
|
189
|
+
return f"{self.input_subscripts}->{self.output_subscript}"
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _preferred_label_style_index(label: str, total_slots: int) -> int | None:
|
|
193
|
+
"""Return the preferred stable palette slot for a label."""
|
|
194
|
+
if not label or not label[0].isalpha():
|
|
195
|
+
return None
|
|
196
|
+
return int.from_bytes(sha1(label.encode("utf-8")).digest()[:2], "big") % (
|
|
197
|
+
total_slots
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@cached_property
|
|
201
|
+
def _label_styles(self) -> dict[str, str]:
|
|
202
|
+
"""Assign non-colliding styles for the active labels in this expression."""
|
|
203
|
+
labels = list(dict.fromkeys(ch for ch in self.eq if ch.isalpha()))
|
|
204
|
+
slot_count = max(64, len(labels))
|
|
205
|
+
label_styles = tuple(
|
|
206
|
+
f"bold {color}" for color in rich_label_palette(slot_count)
|
|
207
|
+
)
|
|
208
|
+
used_slots: set[int] = set()
|
|
209
|
+
styles: dict[str, str] = {}
|
|
210
|
+
total_slots = len(label_styles)
|
|
211
|
+
|
|
212
|
+
for label in labels:
|
|
213
|
+
preferred = self._preferred_label_style_index(label, total_slots)
|
|
214
|
+
if preferred is None:
|
|
215
|
+
styles[label] = "bold"
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if not used_slots:
|
|
219
|
+
used_slots.add(preferred)
|
|
220
|
+
styles[label] = label_styles[preferred]
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
best_slot: int | None = None
|
|
224
|
+
best_score: tuple[float, int] | None = None
|
|
225
|
+
for slot, style in enumerate(label_styles):
|
|
226
|
+
if slot in used_slots:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
color = style.rsplit(" ", 1)[-1]
|
|
230
|
+
min_distance = min(
|
|
231
|
+
rgb_distance_hex(color, label_styles[used_slot].rsplit(" ", 1)[-1])
|
|
232
|
+
for used_slot in used_slots
|
|
233
|
+
)
|
|
234
|
+
circular_preference_distance = min(
|
|
235
|
+
(slot - preferred) % total_slots,
|
|
236
|
+
(preferred - slot) % total_slots,
|
|
237
|
+
)
|
|
238
|
+
score = (min_distance, -circular_preference_distance)
|
|
239
|
+
if best_score is None or score > best_score:
|
|
240
|
+
best_score = score
|
|
241
|
+
best_slot = slot
|
|
242
|
+
|
|
243
|
+
assert best_slot is not None
|
|
244
|
+
used_slots.add(best_slot)
|
|
245
|
+
styles[label] = label_styles[best_slot]
|
|
246
|
+
|
|
247
|
+
return styles
|
|
248
|
+
|
|
249
|
+
def _label_style(self, label: str) -> str:
|
|
250
|
+
"""Return the resolved style for a label within this expression."""
|
|
251
|
+
return self._label_styles.get(label, "bold")
|
|
252
|
+
|
|
253
|
+
def _style_text_charwise(self, text: str):
|
|
254
|
+
from rich.text import Text
|
|
255
|
+
|
|
256
|
+
result = Text()
|
|
257
|
+
for ch in text:
|
|
258
|
+
if ch.isalpha():
|
|
259
|
+
result.append(ch, style=self._label_style(ch))
|
|
260
|
+
elif ch in ",->[]{}()<>✓×:":
|
|
261
|
+
result.append(ch, style="dim")
|
|
262
|
+
else:
|
|
263
|
+
result.append(ch)
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
def _rich_symmetry_token_text(self, token: str):
|
|
267
|
+
from rich.text import Text
|
|
268
|
+
|
|
269
|
+
if token == "-":
|
|
270
|
+
return Text("-", style="dim")
|
|
271
|
+
if token in {"×", "→"}:
|
|
272
|
+
return Text(token, style="dim")
|
|
273
|
+
if token.startswith("PermGroup⟨"):
|
|
274
|
+
return self._style_text_charwise(token)
|
|
275
|
+
|
|
276
|
+
result = Text()
|
|
277
|
+
if token.startswith("W"):
|
|
278
|
+
sym_style = _RICH_SYMMETRY_STYLES["W"]
|
|
279
|
+
result.append("W", style=sym_style)
|
|
280
|
+
if token.startswith("W✓"):
|
|
281
|
+
result.append("✓", style=sym_style)
|
|
282
|
+
if ":" in token:
|
|
283
|
+
result.append(":", style=sym_style)
|
|
284
|
+
remainder = token.split(":", 1)[1].lstrip() if ":" in token else token[1:]
|
|
285
|
+
if remainder:
|
|
286
|
+
result.append(" ", style="dim")
|
|
287
|
+
result.append_text(self._rich_symmetry_token_text(remainder))
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
if token[0] in _RICH_SYMMETRY_STYLES and token[1:].split("{", 1)[0].isdigit():
|
|
291
|
+
prefix = token[0]
|
|
292
|
+
digits = []
|
|
293
|
+
i = 1
|
|
294
|
+
while i < len(token) and token[i].isdigit():
|
|
295
|
+
digits.append(token[i])
|
|
296
|
+
i += 1
|
|
297
|
+
result.append(prefix, style=_RICH_SYMMETRY_STYLES[prefix])
|
|
298
|
+
result.append("".join(digits), style=_RICH_SYMMETRY_STYLES[prefix])
|
|
299
|
+
if i < len(token) and token[i] == "{":
|
|
300
|
+
result.append("{", style="dim")
|
|
301
|
+
i += 1
|
|
302
|
+
while i < len(token) and token[i] != "}":
|
|
303
|
+
ch = token[i]
|
|
304
|
+
if ch.isalpha():
|
|
305
|
+
result.append(ch, style=self._label_style(ch))
|
|
306
|
+
elif ch == ",":
|
|
307
|
+
result.append(ch, style="dim")
|
|
308
|
+
else:
|
|
309
|
+
result.append(ch)
|
|
310
|
+
i += 1
|
|
311
|
+
if i < len(token) and token[i] == "}":
|
|
312
|
+
result.append("}", style="dim")
|
|
313
|
+
return result
|
|
314
|
+
|
|
315
|
+
return self._style_text_charwise(token)
|
|
316
|
+
|
|
317
|
+
def _rich_step_sym_text(self, step: StepInfo):
|
|
318
|
+
from rich.text import Text
|
|
319
|
+
|
|
320
|
+
in_parts = [self._fmt_sym(s) for s in step.input_groups]
|
|
321
|
+
out_part = self._fmt_sym(step.output_group) # type: ignore[arg-type]
|
|
322
|
+
w_part = self._fmt_sym(step.inner_group) # type: ignore[arg-type]
|
|
323
|
+
if all(p == "-" for p in in_parts) and out_part == "-" and w_part == "-":
|
|
324
|
+
return Text("-", style="dim")
|
|
325
|
+
|
|
326
|
+
result = Text()
|
|
327
|
+
for idx, part in enumerate(in_parts):
|
|
328
|
+
if idx:
|
|
329
|
+
result.append(" × ", style="dim")
|
|
330
|
+
result.append_text(self._rich_symmetry_token_text(part))
|
|
331
|
+
result.append(" → ", style="dim")
|
|
332
|
+
result.append_text(self._rich_symmetry_token_text(out_part))
|
|
333
|
+
if w_part != "-":
|
|
334
|
+
result.append(" [", style="dim")
|
|
335
|
+
result.append(
|
|
336
|
+
"W✓" if step.inner_applied else "W", style=_RICH_SYMMETRY_STYLES["W"]
|
|
337
|
+
)
|
|
338
|
+
result.append(": ", style="dim")
|
|
339
|
+
result.append_text(self._rich_symmetry_token_text(w_part))
|
|
340
|
+
result.append("]", style="dim")
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
def _rich_eq_text(self):
|
|
344
|
+
"""Render the full einsum expression with global label styling."""
|
|
345
|
+
from rich.text import Text
|
|
346
|
+
|
|
347
|
+
result = Text()
|
|
348
|
+
prefix = "Complete contraction: "
|
|
349
|
+
result.append(prefix, style="bold")
|
|
350
|
+
result.append_text(self._style_text_charwise(self.eq))
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
def _rich_subscript_text(self, subscript: str):
|
|
354
|
+
"""Render a subscript or step expression with global label styling."""
|
|
355
|
+
return self._style_text_charwise(subscript)
|
|
356
|
+
|
|
357
|
+
def _rich_index_sizes_text(self):
|
|
358
|
+
"""Render the index-size summary with label styling."""
|
|
359
|
+
return self._style_text_charwise(self._fmt_index_sizes())
|
|
360
|
+
|
|
361
|
+
def _fmt_overall_savings(self) -> str:
|
|
362
|
+
"""Format total optimized-vs-dense savings for the whole contraction."""
|
|
363
|
+
if self.naive_cost <= 0:
|
|
364
|
+
return "0.0%"
|
|
365
|
+
return f"{1 - (self.optimized_cost / self.naive_cost):.1%}"
|
|
366
|
+
|
|
367
|
+
def _rich_metric_pill(
|
|
368
|
+
self,
|
|
369
|
+
label: str,
|
|
370
|
+
value: str | Any,
|
|
371
|
+
*,
|
|
372
|
+
highlight: bool = False,
|
|
373
|
+
value_style: str | None = None,
|
|
374
|
+
border_style: str | None = None,
|
|
375
|
+
):
|
|
376
|
+
from rich import box
|
|
377
|
+
from rich.panel import Panel
|
|
378
|
+
from rich.text import Text
|
|
379
|
+
|
|
380
|
+
resolved_value_style = value_style or ("bold cyan" if highlight else "bold")
|
|
381
|
+
resolved_border_style = border_style or ("cyan" if highlight else "dim")
|
|
382
|
+
body = Text()
|
|
383
|
+
body.append(label, style="bold")
|
|
384
|
+
body.append(": ", style="dim")
|
|
385
|
+
if not value:
|
|
386
|
+
body.append("-", style=resolved_value_style)
|
|
387
|
+
elif isinstance(value, Text):
|
|
388
|
+
if highlight:
|
|
389
|
+
value = value.copy()
|
|
390
|
+
value.stylize(resolved_value_style)
|
|
391
|
+
body.append_text(value)
|
|
392
|
+
else:
|
|
393
|
+
body.append(str(value), style=resolved_value_style)
|
|
394
|
+
return Panel.fit(
|
|
395
|
+
body,
|
|
396
|
+
box=box.ROUNDED,
|
|
397
|
+
padding=(0, 1),
|
|
398
|
+
border_style=resolved_border_style,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _rich_summary_strip(self):
|
|
402
|
+
from rich.columns import Columns
|
|
403
|
+
|
|
404
|
+
pills = []
|
|
405
|
+
pills.append(self._rich_metric_pill("Naive cost", f"{self.naive_cost:,}"))
|
|
406
|
+
pills.append(
|
|
407
|
+
self._rich_metric_pill(
|
|
408
|
+
"Optimized cost", f"{self.optimized_cost:,}", highlight=True
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
speedup_style = "bold green" if self.speedup > 1 else "bold"
|
|
412
|
+
speedup_border = "green" if self.speedup > 1 else "dim"
|
|
413
|
+
pills.append(
|
|
414
|
+
self._rich_metric_pill(
|
|
415
|
+
"Speedup",
|
|
416
|
+
f"{self.speedup:.3f}x",
|
|
417
|
+
value_style=speedup_style,
|
|
418
|
+
border_style=speedup_border,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
savings_style = (
|
|
422
|
+
"bold green" if self.optimized_cost < self.naive_cost else "bold"
|
|
423
|
+
)
|
|
424
|
+
savings_border = "green" if self.optimized_cost < self.naive_cost else "dim"
|
|
425
|
+
pills.append(
|
|
426
|
+
self._rich_metric_pill(
|
|
427
|
+
"Savings",
|
|
428
|
+
self._fmt_overall_savings(),
|
|
429
|
+
value_style=savings_style,
|
|
430
|
+
border_style=savings_border,
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
pills.append(
|
|
434
|
+
self._rich_metric_pill(
|
|
435
|
+
"Largest intermediate", f"{self.largest_intermediate:,} elements"
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
if self.size_dict:
|
|
439
|
+
pills.append(
|
|
440
|
+
self._rich_metric_pill("Index sizes", self._rich_index_sizes_text())
|
|
441
|
+
)
|
|
442
|
+
if self.optimizer_used:
|
|
443
|
+
pills.append(self._rich_metric_pill("Optimizer", self.optimizer_used))
|
|
444
|
+
return Columns(pills, expand=True, equal=False, padding=(0, 1))
|
|
445
|
+
|
|
446
|
+
def _rich_verbose_detail_text(
|
|
447
|
+
self, step: StepInfo, cumulative: int, *, step_index: int | None = None
|
|
448
|
+
):
|
|
449
|
+
from rich.text import Text
|
|
450
|
+
|
|
451
|
+
shape = (
|
|
452
|
+
"(" + ",".join(str(d) for d in step.output_shape) + ")"
|
|
453
|
+
if step.output_shape
|
|
454
|
+
else "()"
|
|
455
|
+
)
|
|
456
|
+
result = Text()
|
|
457
|
+
if step_index is not None:
|
|
458
|
+
result.append(f"step {step_index}: ", style="dim")
|
|
459
|
+
result.append("subset=", style="dim")
|
|
460
|
+
result.append(self._fmt_subset(step.merged_subset), style="bold")
|
|
461
|
+
result.append("\n")
|
|
462
|
+
result.append("out_shape=", style="dim")
|
|
463
|
+
result.append(shape, style="bold")
|
|
464
|
+
result.append("\n")
|
|
465
|
+
result.append("cumulative=", style="dim")
|
|
466
|
+
result.append(f"{cumulative:,}", style="bold cyan")
|
|
467
|
+
# NEW: per-step M / α / −O from attached accumulation.
|
|
468
|
+
acc_step = getattr(step, "_acc_step", None)
|
|
469
|
+
if acc_step is not None:
|
|
470
|
+
m_value = acc_step.m_total
|
|
471
|
+
alpha_value = acc_step.alpha or 0
|
|
472
|
+
o_value = (
|
|
473
|
+
acc_step.per_component[0].num_output_orbits
|
|
474
|
+
if acc_step.per_component
|
|
475
|
+
else 0
|
|
476
|
+
)
|
|
477
|
+
result.append("\n")
|
|
478
|
+
result.append("M=", style="dim")
|
|
479
|
+
result.append(str(m_value), style="bold")
|
|
480
|
+
result.append(" α=", style="dim")
|
|
481
|
+
result.append(str(alpha_value), style="bold")
|
|
482
|
+
result.append(" −O=", style="dim")
|
|
483
|
+
result.append(str(o_value), style="bold")
|
|
484
|
+
return result
|
|
485
|
+
|
|
486
|
+
def _fmt_index_sizes(self) -> str:
|
|
487
|
+
"""Format index sizes compactly. Groups indices with the same size."""
|
|
488
|
+
if not self.size_dict:
|
|
489
|
+
return ""
|
|
490
|
+
from collections import defaultdict
|
|
491
|
+
|
|
492
|
+
by_size: dict[int, list[str]] = defaultdict(list)
|
|
493
|
+
for idx, sz in self.size_dict.items():
|
|
494
|
+
by_size[sz].append(idx)
|
|
495
|
+
parts = []
|
|
496
|
+
for sz, idxs in sorted(by_size.items(), key=lambda kv: (-len(kv[1]), -kv[0])):
|
|
497
|
+
idxs_sorted = sorted(idxs)
|
|
498
|
+
parts.append(f"{'='.join(idxs_sorted)}={sz}")
|
|
499
|
+
return ", ".join(parts)
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def _fmt_contract(step: StepInfo) -> str:
|
|
503
|
+
"""Format the path-supplied contraction tuple, e.g. '(0, 1)'."""
|
|
504
|
+
if not step.path_indices:
|
|
505
|
+
return "-"
|
|
506
|
+
if len(step.path_indices) == 2:
|
|
507
|
+
return f"({step.path_indices[0]}, {step.path_indices[1]})"
|
|
508
|
+
return "(" + ",".join(str(p) for p in step.path_indices) + ")"
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def _try_named_group(k: int, order: int) -> str | None:
|
|
512
|
+
"""Return the named prefix (e.g. 'S3') if recognised, else None."""
|
|
513
|
+
if order == 1:
|
|
514
|
+
return None
|
|
515
|
+
from math import factorial
|
|
516
|
+
|
|
517
|
+
if order == factorial(k):
|
|
518
|
+
return f"S{k}"
|
|
519
|
+
if order == k:
|
|
520
|
+
return f"C{k}"
|
|
521
|
+
if order == 2 * k and k >= 3:
|
|
522
|
+
return f"D{k}"
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
@staticmethod
|
|
526
|
+
def _fmt_generators(group: SymmetryGroup, labels: tuple) -> str:
|
|
527
|
+
"""Format generators in cycle notation with labels."""
|
|
528
|
+
parts = []
|
|
529
|
+
for gen in group.generators:
|
|
530
|
+
if gen.is_identity:
|
|
531
|
+
continue
|
|
532
|
+
cycles = gen.cyclic_form
|
|
533
|
+
if not cycles:
|
|
534
|
+
continue
|
|
535
|
+
perm_str = "".join(
|
|
536
|
+
"(" + " ".join(labels[i] for i in cycle) + ")" for cycle in cycles
|
|
537
|
+
)
|
|
538
|
+
parts.append(perm_str)
|
|
539
|
+
return ", ".join(parts) if parts else "e"
|
|
540
|
+
|
|
541
|
+
def _fmt_sym(self, group: SymmetryGroup | None) -> str:
|
|
542
|
+
"""Format a SymmetryGroup for display."""
|
|
543
|
+
if group is None:
|
|
544
|
+
return "-"
|
|
545
|
+
labels = group._labels or tuple(str(i) for i in range(group.degree))
|
|
546
|
+
k = group.degree
|
|
547
|
+
order = group.order()
|
|
548
|
+
|
|
549
|
+
name = self._try_named_group(k, order)
|
|
550
|
+
if name is not None:
|
|
551
|
+
return f"{name}{{{','.join(labels)}}}"
|
|
552
|
+
|
|
553
|
+
orbits = [orb for orb in group.orbits() if len(orb) >= 2]
|
|
554
|
+
if not orbits:
|
|
555
|
+
return "-"
|
|
556
|
+
|
|
557
|
+
if len(orbits) == 1:
|
|
558
|
+
orbit = orbits[0]
|
|
559
|
+
moved_labels = tuple(labels[i] for i in sorted(orbit))
|
|
560
|
+
mk = len(moved_labels)
|
|
561
|
+
name = self._try_named_group(mk, order)
|
|
562
|
+
if name is not None:
|
|
563
|
+
return f"{name}{{{','.join(moved_labels)}}}"
|
|
564
|
+
|
|
565
|
+
gen_str = self._fmt_generators(group, labels)
|
|
566
|
+
return f"PermGroup⟨{gen_str}⟩"
|
|
567
|
+
|
|
568
|
+
def _fmt_step_regime(self, step) -> str:
|
|
569
|
+
"""Return the regime name for a step, or '-' when unknown.
|
|
570
|
+
|
|
571
|
+
FlopscopePathInfo.__str__ patches `_regime` per step from
|
|
572
|
+
accumulation.per_step before calling format_table.
|
|
573
|
+
"""
|
|
574
|
+
return getattr(step, "_regime", "-")
|
|
575
|
+
|
|
576
|
+
def _fmt_step_sym(self, step: StepInfo) -> str:
|
|
577
|
+
"""Format inputs→output symmetry transformation for one step."""
|
|
578
|
+
in_parts = [self._fmt_sym(s) for s in step.input_groups]
|
|
579
|
+
out_part = self._fmt_sym(step.output_group) # type: ignore[arg-type]
|
|
580
|
+
w_part = self._fmt_sym(step.inner_group) # type: ignore[arg-type]
|
|
581
|
+
if all(p == "-" for p in in_parts) and out_part == "-" and w_part == "-":
|
|
582
|
+
return ""
|
|
583
|
+
result = f"{' × '.join(in_parts)} → {out_part}"
|
|
584
|
+
if w_part != "-":
|
|
585
|
+
w_prefix = "W✓" if step.inner_applied else "W"
|
|
586
|
+
result += f" [{w_prefix}: {w_part}]"
|
|
587
|
+
return result
|
|
588
|
+
|
|
589
|
+
def _fmt_unique_dense(self, step: StepInfo) -> str:
|
|
590
|
+
"""Show output and inner unique/dense element counts."""
|
|
591
|
+
from math import prod
|
|
592
|
+
|
|
593
|
+
def _unique_elements(
|
|
594
|
+
indices: frozenset[str],
|
|
595
|
+
size_dict: dict[str, int],
|
|
596
|
+
perm_group: SymmetryGroup | None,
|
|
597
|
+
) -> int:
|
|
598
|
+
"""Count unique elements for a set of subscript indices under symmetry."""
|
|
599
|
+
if not indices:
|
|
600
|
+
return 1
|
|
601
|
+
if perm_group is not None:
|
|
602
|
+
labels = perm_group._labels or tuple(
|
|
603
|
+
sorted(indices)[: perm_group.degree]
|
|
604
|
+
)
|
|
605
|
+
pg_size_dict: dict[int, int] = {}
|
|
606
|
+
accounted: set[str] = set()
|
|
607
|
+
for i, lbl in enumerate(labels):
|
|
608
|
+
pg_size_dict[i] = size_dict[lbl]
|
|
609
|
+
accounted.add(lbl)
|
|
610
|
+
count = perm_group.burnside_unique_count(pg_size_dict)
|
|
611
|
+
for idx in indices:
|
|
612
|
+
if idx not in accounted:
|
|
613
|
+
count *= size_dict[idx]
|
|
614
|
+
return count
|
|
615
|
+
return prod(size_dict[i] for i in indices)
|
|
616
|
+
|
|
617
|
+
if step.flop_cost == step.dense_flop_cost:
|
|
618
|
+
return "-"
|
|
619
|
+
|
|
620
|
+
parts: list[str] = []
|
|
621
|
+
|
|
622
|
+
if step.output_group is not None and step.output_shape:
|
|
623
|
+
out_str = step.subscript.split("->")[1] if "->" in step.subscript else ""
|
|
624
|
+
out_total = prod(step.output_shape)
|
|
625
|
+
out_unique = _unique_elements(
|
|
626
|
+
frozenset(out_str),
|
|
627
|
+
self.size_dict,
|
|
628
|
+
perm_group=step.output_group, # type: ignore[arg-type]
|
|
629
|
+
)
|
|
630
|
+
if out_unique != out_total:
|
|
631
|
+
parts.append(f"V:{out_unique:,}/{out_total:,}")
|
|
632
|
+
|
|
633
|
+
if step.inner_applied and step.inner_group is not None:
|
|
634
|
+
lhs = (
|
|
635
|
+
step.subscript.split("->")[0]
|
|
636
|
+
if "->" in step.subscript
|
|
637
|
+
else step.subscript
|
|
638
|
+
)
|
|
639
|
+
out_str = step.subscript.split("->")[1] if "->" in step.subscript else ""
|
|
640
|
+
contracted = frozenset(lhs.replace(",", "")) - frozenset(out_str)
|
|
641
|
+
if contracted:
|
|
642
|
+
inner_total = prod(self.size_dict[c] for c in contracted)
|
|
643
|
+
inner_unique = _unique_elements(
|
|
644
|
+
contracted,
|
|
645
|
+
self.size_dict,
|
|
646
|
+
perm_group=step.inner_group, # type: ignore[arg-type]
|
|
647
|
+
)
|
|
648
|
+
if inner_unique != inner_total:
|
|
649
|
+
parts.append(f"W:{inner_unique:,}/{inner_total:,}")
|
|
650
|
+
|
|
651
|
+
return " ".join(parts) if parts else "-"
|
|
652
|
+
|
|
653
|
+
@staticmethod
|
|
654
|
+
def _fmt_subset(s: frozenset[int] | None) -> str:
|
|
655
|
+
if s is None:
|
|
656
|
+
return "-"
|
|
657
|
+
if not s:
|
|
658
|
+
return "{}"
|
|
659
|
+
return "{" + ",".join(str(i) for i in sorted(s)) + "}"
|
|
660
|
+
|
|
661
|
+
def _header_lines(self) -> list[str]:
|
|
662
|
+
sizes_line = self._fmt_index_sizes()
|
|
663
|
+
header_lines = [
|
|
664
|
+
f" Complete contraction: {self.eq}",
|
|
665
|
+
f" Naive cost (flopscope): {self.naive_cost:,}",
|
|
666
|
+
f" Optimized cost (flopscope): {self.optimized_cost:,}",
|
|
667
|
+
f" Speedup: {self.speedup:.3f}x",
|
|
668
|
+
f" Savings: {self._fmt_overall_savings()}",
|
|
669
|
+
f" Largest intermediate: {self.largest_intermediate:,} elements",
|
|
670
|
+
]
|
|
671
|
+
if sizes_line:
|
|
672
|
+
header_lines.append(f" Index sizes: {sizes_line}")
|
|
673
|
+
if self.optimizer_used:
|
|
674
|
+
header_lines.append(f" Optimizer: {self.optimizer_used}")
|
|
675
|
+
return header_lines
|
|
676
|
+
|
|
677
|
+
def _rich_step_table(self, verbose: bool = False):
|
|
678
|
+
from rich import box
|
|
679
|
+
from rich.table import Table
|
|
680
|
+
|
|
681
|
+
any_unique = any(
|
|
682
|
+
s.dense_flop_cost > 0 and s.flop_cost != s.dense_flop_cost
|
|
683
|
+
for s in self.steps
|
|
684
|
+
)
|
|
685
|
+
any_regime = any(hasattr(s, "_regime") for s in self.steps)
|
|
686
|
+
|
|
687
|
+
contract_width = max(
|
|
688
|
+
len("contract"),
|
|
689
|
+
max((len(self._fmt_contract(step)) for step in self.steps), default=0),
|
|
690
|
+
)
|
|
691
|
+
subscript_width = min(
|
|
692
|
+
24,
|
|
693
|
+
max(
|
|
694
|
+
len("subscript"),
|
|
695
|
+
max((len(step.subscript) for step in self.steps), default=0),
|
|
696
|
+
),
|
|
697
|
+
)
|
|
698
|
+
flops_width = max(
|
|
699
|
+
len("flops"),
|
|
700
|
+
max((len(f"{step.flop_cost:,}") for step in self.steps), default=0),
|
|
701
|
+
)
|
|
702
|
+
dense_width = max(
|
|
703
|
+
len("dense_flops"),
|
|
704
|
+
max((len(f"{step.dense_flop_cost:,}") for step in self.steps), default=0),
|
|
705
|
+
)
|
|
706
|
+
savings_width = max(
|
|
707
|
+
len("savings"),
|
|
708
|
+
max(
|
|
709
|
+
(len(f"{step.symmetry_savings:0.1%}") for step in self.steps), default=0
|
|
710
|
+
),
|
|
711
|
+
)
|
|
712
|
+
blas_width = max(
|
|
713
|
+
len("blas"),
|
|
714
|
+
max(
|
|
715
|
+
(
|
|
716
|
+
len(str(step.blas_type) if step.blas_type else "-")
|
|
717
|
+
for step in self.steps
|
|
718
|
+
),
|
|
719
|
+
default=0,
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
regime_width = None
|
|
723
|
+
if any_regime:
|
|
724
|
+
regime_width = max(
|
|
725
|
+
len("regime"),
|
|
726
|
+
max(
|
|
727
|
+
(len(getattr(step, "_regime", "-")) for step in self.steps),
|
|
728
|
+
default=len("regime"),
|
|
729
|
+
),
|
|
730
|
+
)
|
|
731
|
+
unique_width = None
|
|
732
|
+
if any_unique:
|
|
733
|
+
unique_width = max(
|
|
734
|
+
len("unique/total"),
|
|
735
|
+
max(
|
|
736
|
+
(len(self._fmt_unique_dense(step)) for step in self.steps),
|
|
737
|
+
default=0,
|
|
738
|
+
),
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
table = Table(
|
|
742
|
+
show_header=True,
|
|
743
|
+
header_style="bold",
|
|
744
|
+
expand=True,
|
|
745
|
+
box=box.HEAVY,
|
|
746
|
+
pad_edge=False,
|
|
747
|
+
padding=(0, 1),
|
|
748
|
+
collapse_padding=True,
|
|
749
|
+
)
|
|
750
|
+
table.add_column("step", justify="right", no_wrap=True, width=len("step"))
|
|
751
|
+
table.add_column("contract", justify="left", no_wrap=True, width=contract_width)
|
|
752
|
+
table.add_column("subscript", overflow="fold", width=subscript_width)
|
|
753
|
+
if any_regime:
|
|
754
|
+
table.add_column("regime", justify="left", no_wrap=True, width=regime_width)
|
|
755
|
+
table.add_column("flops", justify="right", no_wrap=True, width=flops_width)
|
|
756
|
+
table.add_column(
|
|
757
|
+
"dense_flops", justify="right", no_wrap=True, width=dense_width
|
|
758
|
+
)
|
|
759
|
+
table.add_column("savings", justify="right", no_wrap=True, width=savings_width)
|
|
760
|
+
table.add_column("blas", no_wrap=True, width=blas_width)
|
|
761
|
+
if any_unique:
|
|
762
|
+
table.add_column("unique/total", no_wrap=True, width=unique_width)
|
|
763
|
+
table.add_column(
|
|
764
|
+
"symmetry (inputs → output)",
|
|
765
|
+
overflow="fold",
|
|
766
|
+
min_width=len("symmetry (inputs → output)"),
|
|
767
|
+
ratio=1,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
cumulative = 0
|
|
771
|
+
for i, step in enumerate(self.steps):
|
|
772
|
+
row = [
|
|
773
|
+
str(i),
|
|
774
|
+
self._fmt_contract(step),
|
|
775
|
+
self._rich_subscript_text(step.subscript),
|
|
776
|
+
]
|
|
777
|
+
if any_regime:
|
|
778
|
+
row.append(getattr(step, "_regime", "-"))
|
|
779
|
+
row += [
|
|
780
|
+
f"{step.flop_cost:,}",
|
|
781
|
+
f"{step.dense_flop_cost:,}",
|
|
782
|
+
f"{step.symmetry_savings:>7.1%}",
|
|
783
|
+
str(step.blas_type) if step.blas_type else "-",
|
|
784
|
+
]
|
|
785
|
+
if any_unique:
|
|
786
|
+
row.append(self._fmt_unique_dense(step))
|
|
787
|
+
row.append(self._rich_step_sym_text(step) or "-")
|
|
788
|
+
table.add_row(*row)
|
|
789
|
+
# Sprint 3 (#55): pre-reduction sub-rows
|
|
790
|
+
pre_reductions = step.pre_reductions
|
|
791
|
+
if pre_reductions:
|
|
792
|
+
from rich.text import Text
|
|
793
|
+
|
|
794
|
+
lhs = step.subscript.split("->")[0]
|
|
795
|
+
operand_subs = lhs.split(",")
|
|
796
|
+
for pre in pre_reductions:
|
|
797
|
+
op_name = f"op{pre.operand_index}"
|
|
798
|
+
labels_str = ",".join(pre.removed_labels)
|
|
799
|
+
original_sub = (
|
|
800
|
+
operand_subs[pre.operand_index]
|
|
801
|
+
if 0 <= pre.operand_index < len(operand_subs)
|
|
802
|
+
else ""
|
|
803
|
+
)
|
|
804
|
+
sub_text = Text(
|
|
805
|
+
f" pre-reduce {op_name} {{{labels_str}}}: "
|
|
806
|
+
f"{pre.cost:,} ops "
|
|
807
|
+
f"(subscript: {original_sub} → {pre.surviving_subscript})",
|
|
808
|
+
style="dim",
|
|
809
|
+
)
|
|
810
|
+
detail_row = [""] * len(table.columns)
|
|
811
|
+
detail_row[2] = sub_text # type: ignore[call-overload, assignment]
|
|
812
|
+
table.add_row(*detail_row)
|
|
813
|
+
# Residual contraction sub-row
|
|
814
|
+
residual_cost = step.flop_cost - sum(p.cost for p in pre_reductions)
|
|
815
|
+
effective_subs = list(operand_subs)
|
|
816
|
+
for pre in pre_reductions:
|
|
817
|
+
if 0 <= pre.operand_index < len(effective_subs):
|
|
818
|
+
effective_subs[pre.operand_index] = pre.surviving_subscript
|
|
819
|
+
step_output_part = (
|
|
820
|
+
step.subscript.split("->")[1] if "->" in step.subscript else ""
|
|
821
|
+
)
|
|
822
|
+
residual_subscript = ",".join(effective_subs) + "->" + step_output_part
|
|
823
|
+
detail_row = [""] * len(table.columns)
|
|
824
|
+
detail_row[2] = Text( # type: ignore[call-overload, assignment]
|
|
825
|
+
f" residual contraction: {residual_subscript} → "
|
|
826
|
+
f"{residual_cost:,} ops",
|
|
827
|
+
style="dim",
|
|
828
|
+
)
|
|
829
|
+
table.add_row(*detail_row)
|
|
830
|
+
if verbose:
|
|
831
|
+
cumulative += step.flop_cost
|
|
832
|
+
detail_row = [""] * len(table.columns)
|
|
833
|
+
detail_row[2] = self._rich_verbose_detail_text(step, cumulative) # type: ignore[call-overload, assignment]
|
|
834
|
+
detail_row[-1] = self._rich_verbose_detail_text( # type: ignore[call-overload, assignment]
|
|
835
|
+
step, cumulative, step_index=i
|
|
836
|
+
)
|
|
837
|
+
table.add_row(*detail_row)
|
|
838
|
+
|
|
839
|
+
return table
|
|
840
|
+
|
|
841
|
+
def _rich_renderable(self, verbose: bool = False):
|
|
842
|
+
from rich.console import Group
|
|
843
|
+
from rich.panel import Panel
|
|
844
|
+
|
|
845
|
+
expr = self._rich_eq_text()
|
|
846
|
+
summary = self._rich_summary_strip()
|
|
847
|
+
table = self._rich_step_table(verbose=verbose)
|
|
848
|
+
return Panel(
|
|
849
|
+
Group(expr, summary, table),
|
|
850
|
+
title="[bold cyan]einsum_path[/bold cyan]",
|
|
851
|
+
border_style="cyan",
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def format_table(self, verbose: bool = False) -> str:
|
|
855
|
+
"""Render the path info as a printable table.
|
|
856
|
+
|
|
857
|
+
Parameters
|
|
858
|
+
----------
|
|
859
|
+
verbose : bool, optional
|
|
860
|
+
When True, emit an additional indented details row under each
|
|
861
|
+
step showing the operand subset covered by the intermediate,
|
|
862
|
+
its output shape, the unique-vs-dense element counts that the
|
|
863
|
+
symmetry savings derive from, and the cumulative cost so far.
|
|
864
|
+
Useful for debugging why a particular step's savings are what
|
|
865
|
+
they are. Default False.
|
|
866
|
+
"""
|
|
867
|
+
sym_strs = [self._fmt_step_sym(s) for s in self.steps]
|
|
868
|
+
max_sym_width = max((len(s) for s in sym_strs), default=0)
|
|
869
|
+
header_lines = self._header_lines()
|
|
870
|
+
|
|
871
|
+
# Common columns: step, contract, subscript, regime, flops, dense_flops, savings, blas
|
|
872
|
+
# Plus: symmetry (when any step has symmetry) and unique/dense (when any
|
|
873
|
+
# step has reduced cost).
|
|
874
|
+
any_unique = any(
|
|
875
|
+
s.dense_flop_cost > 0 and s.flop_cost != s.dense_flop_cost
|
|
876
|
+
for s in self.steps
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
contract_strs = [self._fmt_contract(s) for s in self.steps]
|
|
880
|
+
contract_col_width = max(
|
|
881
|
+
len("contract"), max((len(c) for c in contract_strs), default=0)
|
|
882
|
+
)
|
|
883
|
+
unique_col_width = max(
|
|
884
|
+
len("unique/total"),
|
|
885
|
+
max((len(self._fmt_unique_dense(s)) for s in self.steps), default=0),
|
|
886
|
+
)
|
|
887
|
+
regime_strs = [self._fmt_step_regime(s) for s in self.steps]
|
|
888
|
+
regime_col_width = max(
|
|
889
|
+
len("regime"), max((len(r) for r in regime_strs), default=0)
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
# Build the header line
|
|
893
|
+
cols = [
|
|
894
|
+
f"{'step':>4}",
|
|
895
|
+
f"{'contract':<{contract_col_width}}",
|
|
896
|
+
f"{'subscript':<30}",
|
|
897
|
+
f"{'regime':<{regime_col_width}}",
|
|
898
|
+
f"{'flops':>14}",
|
|
899
|
+
f"{'dense_flops':>14}",
|
|
900
|
+
f"{'savings':>8}",
|
|
901
|
+
f"{'blas':<8}",
|
|
902
|
+
]
|
|
903
|
+
if any_unique:
|
|
904
|
+
cols.append(f"{'unique/total':<{unique_col_width}}")
|
|
905
|
+
sym_col_width = min(max(max_sym_width, len("symmetry (inputs → output)")), 60)
|
|
906
|
+
cols.append(f"{'symmetry (inputs → output)':<{sym_col_width}}")
|
|
907
|
+
|
|
908
|
+
header_row = " ".join(cols)
|
|
909
|
+
width = max(len(header_row), 84)
|
|
910
|
+
lines = header_lines + ["-" * width, header_row, "-" * width]
|
|
911
|
+
|
|
912
|
+
cumulative = 0
|
|
913
|
+
for i, step in enumerate(self.steps):
|
|
914
|
+
blas_label = str(step.blas_type) if step.blas_type else "-"
|
|
915
|
+
row_parts = [
|
|
916
|
+
f"{i:>4}",
|
|
917
|
+
f"{contract_strs[i]:<{contract_col_width}}",
|
|
918
|
+
f"{step.subscript:<30}",
|
|
919
|
+
f"{regime_strs[i]:<{regime_col_width}}",
|
|
920
|
+
f"{step.flop_cost:>14,}",
|
|
921
|
+
f"{step.dense_flop_cost:>14,}",
|
|
922
|
+
f"{step.symmetry_savings:>7.1%}",
|
|
923
|
+
f"{blas_label:<8}",
|
|
924
|
+
]
|
|
925
|
+
if any_unique:
|
|
926
|
+
row_parts.append(f"{self._fmt_unique_dense(step):<{unique_col_width}}")
|
|
927
|
+
sym_str = sym_strs[i] or "-"
|
|
928
|
+
if len(sym_str) > sym_col_width:
|
|
929
|
+
sym_str = sym_str[: sym_col_width - 1] + "…"
|
|
930
|
+
row_parts.append(f"{sym_str:<{sym_col_width}}")
|
|
931
|
+
lines.append(" ".join(row_parts))
|
|
932
|
+
|
|
933
|
+
# Sprint 3 (#55): pre-reduction sub-rows
|
|
934
|
+
pre_reductions = step.pre_reductions
|
|
935
|
+
if pre_reductions:
|
|
936
|
+
indent = " " * 8
|
|
937
|
+
lhs = step.subscript.split("->")[0]
|
|
938
|
+
operand_subs = lhs.split(",")
|
|
939
|
+
# Per-pre-reduction sub-row
|
|
940
|
+
for pre in pre_reductions:
|
|
941
|
+
op_name = f"op{pre.operand_index}"
|
|
942
|
+
labels_str = ",".join(pre.removed_labels)
|
|
943
|
+
original_sub = (
|
|
944
|
+
operand_subs[pre.operand_index]
|
|
945
|
+
if 0 <= pre.operand_index < len(operand_subs)
|
|
946
|
+
else ""
|
|
947
|
+
)
|
|
948
|
+
sub_line = (
|
|
949
|
+
f"{indent}pre-reduce {op_name} {{{labels_str}}}: "
|
|
950
|
+
f"{pre.cost:,} ops "
|
|
951
|
+
f"(subscript: {original_sub} → {pre.surviving_subscript})"
|
|
952
|
+
)
|
|
953
|
+
lines.append(sub_line)
|
|
954
|
+
# Residual contraction sub-row
|
|
955
|
+
residual_cost = step.flop_cost - sum(p.cost for p in pre_reductions)
|
|
956
|
+
effective_subs = list(operand_subs)
|
|
957
|
+
for pre in pre_reductions:
|
|
958
|
+
if 0 <= pre.operand_index < len(effective_subs):
|
|
959
|
+
effective_subs[pre.operand_index] = pre.surviving_subscript
|
|
960
|
+
step_output_part = (
|
|
961
|
+
step.subscript.split("->")[1] if "->" in step.subscript else ""
|
|
962
|
+
)
|
|
963
|
+
residual_subscript = ",".join(effective_subs) + "->" + step_output_part
|
|
964
|
+
lines.append(
|
|
965
|
+
f"{indent}residual contraction: {residual_subscript} → "
|
|
966
|
+
f"{residual_cost:,} ops"
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
cumulative += step.flop_cost
|
|
970
|
+
if verbose:
|
|
971
|
+
# Indented details row: subset, out_shape, cumulative cost.
|
|
972
|
+
# Aligned under the subscript column for visual clarity.
|
|
973
|
+
subset_str = self._fmt_subset(step.merged_subset)
|
|
974
|
+
shape_str = (
|
|
975
|
+
"(" + ",".join(str(d) for d in step.output_shape) + ")"
|
|
976
|
+
if step.output_shape
|
|
977
|
+
else "()"
|
|
978
|
+
)
|
|
979
|
+
detail_parts = [
|
|
980
|
+
f"subset={subset_str}",
|
|
981
|
+
f"out_shape={shape_str}",
|
|
982
|
+
f"cumulative={cumulative:,}",
|
|
983
|
+
]
|
|
984
|
+
# NEW: per-step M / α / −O from attached accumulation.
|
|
985
|
+
acc_step = getattr(step, "_acc_step", None)
|
|
986
|
+
if acc_step is not None:
|
|
987
|
+
m_value = acc_step.m_total
|
|
988
|
+
alpha_value = acc_step.alpha or 0
|
|
989
|
+
o_value = (
|
|
990
|
+
acc_step.per_component[0].num_output_orbits
|
|
991
|
+
if acc_step.per_component
|
|
992
|
+
else 0
|
|
993
|
+
)
|
|
994
|
+
detail_parts.append(f"M={m_value} α={alpha_value} −O={o_value}")
|
|
995
|
+
lines.append(" " + " ".join(detail_parts))
|
|
996
|
+
|
|
997
|
+
return "\n".join(lines)
|
|
998
|
+
|
|
999
|
+
def __rich__(self):
|
|
1000
|
+
return self._rich_renderable()
|
|
1001
|
+
|
|
1002
|
+
def print(self, verbose: bool = False) -> None:
|
|
1003
|
+
"""Print using Rich when available, otherwise plain text.
|
|
1004
|
+
|
|
1005
|
+
Notes
|
|
1006
|
+
-----
|
|
1007
|
+
Builtin ``print(info)`` still goes through ``__str__`` and remains
|
|
1008
|
+
the plain-text fallback. This convenience method chooses the Rich
|
|
1009
|
+
renderer whenever Rich is importable, including the Rich verbose
|
|
1010
|
+
layout when ``verbose=True``.
|
|
1011
|
+
"""
|
|
1012
|
+
import builtins
|
|
1013
|
+
|
|
1014
|
+
try:
|
|
1015
|
+
from rich.console import Console
|
|
1016
|
+
except ImportError:
|
|
1017
|
+
builtins.print(self.format_table(verbose=verbose))
|
|
1018
|
+
return None
|
|
1019
|
+
|
|
1020
|
+
if verbose:
|
|
1021
|
+
Console().print(self._rich_renderable(verbose=True))
|
|
1022
|
+
else:
|
|
1023
|
+
Console().print(self)
|
|
1024
|
+
return None
|
|
1025
|
+
|
|
1026
|
+
def __str__(self) -> str:
|
|
1027
|
+
return self.format_table(verbose=False)
|
|
1028
|
+
|
|
1029
|
+
def __repr__(self) -> str:
|
|
1030
|
+
return self.__str__()
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
# ── per-step cost helper ───────────────────────────────────────────
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def symmetric_flop_count(
|
|
1037
|
+
idx_contract,
|
|
1038
|
+
inner,
|
|
1039
|
+
num_terms,
|
|
1040
|
+
size_dict,
|
|
1041
|
+
*,
|
|
1042
|
+
input_subscripts=None,
|
|
1043
|
+
output_subscript=None,
|
|
1044
|
+
input_shapes=None,
|
|
1045
|
+
per_input_groups=None,
|
|
1046
|
+
):
|
|
1047
|
+
"""Per-step symmetry-aware cost. Delegates to compute_accumulation_cost
|
|
1048
|
+
on the binary sub-expression so path-walker per-step costs match
|
|
1049
|
+
accumulation per-step costs by construction.
|
|
1050
|
+
|
|
1051
|
+
When ``per_input_groups`` is provided (a sequence of SymmetryGroup-or-None,
|
|
1052
|
+
one per input), the cache call uses those as the per-operand symmetry
|
|
1053
|
+
fingerprint so propagated intermediate symmetries reduce the per-step
|
|
1054
|
+
cost. When None, falls back to all-None fingerprints (legacy/dense path).
|
|
1055
|
+
|
|
1056
|
+
Legacy fallback: when subscripts/shapes are not provided (older callers),
|
|
1057
|
+
falls back to the dense direct-event count from helpers.flop_count.
|
|
1058
|
+
"""
|
|
1059
|
+
if input_subscripts is None or input_shapes is None:
|
|
1060
|
+
return helpers.flop_count(
|
|
1061
|
+
idx_contraction=idx_contract,
|
|
1062
|
+
inner=inner,
|
|
1063
|
+
num_terms=num_terms,
|
|
1064
|
+
size_dictionary=size_dict,
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
from flopscope._accumulation._cache import get_accumulation_cost_cached
|
|
1068
|
+
from flopscope._config import get_setting
|
|
1069
|
+
|
|
1070
|
+
canonical = ",".join(input_subscripts) + "->" + (output_subscript or "")
|
|
1071
|
+
partition_budget = int(get_setting("partition_budget")) # type: ignore[arg-type]
|
|
1072
|
+
|
|
1073
|
+
if per_input_groups is not None:
|
|
1074
|
+
# Oracle-derived groups encode generators in label-sorted space:
|
|
1075
|
+
# group._labels = alphabetically sorted tuple of subscript chars;
|
|
1076
|
+
# generator[i] = j means label _labels[i] maps to _labels[j].
|
|
1077
|
+
# Source A in _collect_pi_permutations instead expects generators in
|
|
1078
|
+
# tensor-axis space: generator[i] = j means axis i maps to axis j.
|
|
1079
|
+
# Convert by re-expressing each generator via the subscript char order.
|
|
1080
|
+
from flopscope._accumulation._cost import compute_accumulation_cost
|
|
1081
|
+
from flopscope._perm_group import SymmetryGroup as _SG
|
|
1082
|
+
from flopscope._perm_group import _PermutationCompat as _Perm
|
|
1083
|
+
|
|
1084
|
+
def _oracle_group_to_tensor_axes(group: _SG, subscript: str) -> _SG | None:
|
|
1085
|
+
"""Re-express an oracle label-sorted group in tensor-axis space.
|
|
1086
|
+
|
|
1087
|
+
The oracle creates SymmetryGroup with _labels = sorted char tuple
|
|
1088
|
+
and axes = identity range. Source A in the subgraph oracle uses
|
|
1089
|
+
axes[g_pos] as a tensor axis index, so passing the oracle group
|
|
1090
|
+
directly maps position 0 to axis 0, which is wrong when the chars
|
|
1091
|
+
are sorted (not in subscript order).
|
|
1092
|
+
|
|
1093
|
+
This function builds a new SymmetryGroup whose generators act on
|
|
1094
|
+
axis indices of ``subscript`` directly, so Source A interprets them
|
|
1095
|
+
correctly.
|
|
1096
|
+
"""
|
|
1097
|
+
if group is None or group._labels is None:
|
|
1098
|
+
return group # type: ignore[return-value]
|
|
1099
|
+
labels = group._labels # e.g. ('b', 'c', 'i') sorted
|
|
1100
|
+
char_to_axis = {c: i for i, c in enumerate(subscript)}
|
|
1101
|
+
label_to_lpos = {lbl: k for k, lbl in enumerate(labels)}
|
|
1102
|
+
|
|
1103
|
+
new_gens = []
|
|
1104
|
+
for gen in group.generators:
|
|
1105
|
+
arr = list(gen.array_form) # label-space permutation
|
|
1106
|
+
axis_gen = list(range(len(subscript)))
|
|
1107
|
+
for ax, char in enumerate(subscript):
|
|
1108
|
+
if char in label_to_lpos:
|
|
1109
|
+
lpos = label_to_lpos[char]
|
|
1110
|
+
target_char = labels[arr[lpos]]
|
|
1111
|
+
if target_char in char_to_axis:
|
|
1112
|
+
axis_gen[ax] = char_to_axis[target_char]
|
|
1113
|
+
new_gens.append(_Perm(axis_gen))
|
|
1114
|
+
if not new_gens:
|
|
1115
|
+
return None
|
|
1116
|
+
new_grp = _SG(*new_gens, axes=tuple(range(len(subscript))))
|
|
1117
|
+
return new_grp
|
|
1118
|
+
|
|
1119
|
+
converted = tuple(
|
|
1120
|
+
_oracle_group_to_tensor_axes(g, sub) if g is not None else None
|
|
1121
|
+
for g, sub in zip(per_input_groups, input_subscripts, strict=False)
|
|
1122
|
+
)
|
|
1123
|
+
cost = compute_accumulation_cost(
|
|
1124
|
+
canonical_subscripts=canonical,
|
|
1125
|
+
input_parts=tuple(input_subscripts),
|
|
1126
|
+
output_subscript=output_subscript or "",
|
|
1127
|
+
shapes=tuple(tuple(s) for s in input_shapes),
|
|
1128
|
+
per_op_symmetries=converted,
|
|
1129
|
+
identity_pattern=None,
|
|
1130
|
+
partition_budget=partition_budget,
|
|
1131
|
+
)
|
|
1132
|
+
return cost.total
|
|
1133
|
+
|
|
1134
|
+
sym_fingerprint = tuple(None for _ in input_subscripts)
|
|
1135
|
+
cost = get_accumulation_cost_cached(
|
|
1136
|
+
canonical_subscripts=canonical,
|
|
1137
|
+
input_parts=tuple(input_subscripts),
|
|
1138
|
+
output_subscript=output_subscript or "",
|
|
1139
|
+
shapes=tuple(tuple(s) for s in input_shapes),
|
|
1140
|
+
sym_fingerprint=sym_fingerprint,
|
|
1141
|
+
identity_pattern=None,
|
|
1142
|
+
partition_budget=partition_budget,
|
|
1143
|
+
)
|
|
1144
|
+
return cost.total
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
# ── build_path_info adapter (Task 5) ───────────────────────────────
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def build_path_info(
|
|
1151
|
+
upstream_path,
|
|
1152
|
+
upstream_info,
|
|
1153
|
+
*,
|
|
1154
|
+
size_dict,
|
|
1155
|
+
optimizer_used: str = "",
|
|
1156
|
+
per_op_symmetries=None,
|
|
1157
|
+
identity_pattern=None,
|
|
1158
|
+
):
|
|
1159
|
+
"""Adapt upstream opt_einsum's PathInfo to flopscope's PathInfo.
|
|
1160
|
+
|
|
1161
|
+
Per-step ``flop_cost`` is recomputed using flopscope's
|
|
1162
|
+
``_helpers.flop_count`` (FMA = 1 by default; configurable via the
|
|
1163
|
+
``fma_cost`` setting). ``naive_cost`` and ``optimized_cost`` are also
|
|
1164
|
+
recomputed from the per-step costs.
|
|
1165
|
+
|
|
1166
|
+
Parameters
|
|
1167
|
+
----------
|
|
1168
|
+
upstream_path : list[tuple[int, ...]]
|
|
1169
|
+
The contraction path returned by opt_einsum.contract_path.
|
|
1170
|
+
upstream_info : opt_einsum.contract.PathInfo
|
|
1171
|
+
Upstream's PathInfo with contraction_list, naive_cost, etc.
|
|
1172
|
+
size_dict : dict[str, int]
|
|
1173
|
+
Label -> dimension size mapping.
|
|
1174
|
+
optimizer_used : str, optional
|
|
1175
|
+
Name of the optimizer that produced ``upstream_path``. Propagated
|
|
1176
|
+
into the returned PathInfo for display. Defaults to ``''``.
|
|
1177
|
+
per_op_symmetries : sequence of SymmetryGroup or None, optional
|
|
1178
|
+
Per-operand declared symmetries (parallel to operands). When provided,
|
|
1179
|
+
a SubgraphSymmetryOracle is built and queried per step to populate
|
|
1180
|
+
``input_groups``, ``output_group``, and ``inner_group`` on each
|
|
1181
|
+
StepInfo. Defaults to ``None`` (all dense, no symmetry).
|
|
1182
|
+
|
|
1183
|
+
Returns
|
|
1184
|
+
-------
|
|
1185
|
+
PathInfo
|
|
1186
|
+
flopscope's PathInfo with FMA-aware per-step costs.
|
|
1187
|
+
"""
|
|
1188
|
+
from math import prod
|
|
1189
|
+
|
|
1190
|
+
# Walk the contraction list. Each entry has the shape:
|
|
1191
|
+
# (idx_contract: tuple[int,...], idx_removed: frozenset[str],
|
|
1192
|
+
# einsum_str: str, remaining: tuple[str,...] | None, do_blas: bool|str)
|
|
1193
|
+
steps_out: list[StepInfo] = []
|
|
1194
|
+
largest_intermediate = 0
|
|
1195
|
+
|
|
1196
|
+
# Reconstruct merged_subset tracking from the path itself.
|
|
1197
|
+
# upstream_path[i] gives the original (pre-sort) indices for step i.
|
|
1198
|
+
_first_remaining = (
|
|
1199
|
+
upstream_info.contraction_list[0][3]
|
|
1200
|
+
if upstream_info.contraction_list
|
|
1201
|
+
and upstream_info.contraction_list[0][3] is not None
|
|
1202
|
+
else None
|
|
1203
|
+
)
|
|
1204
|
+
num_ops = (
|
|
1205
|
+
(len(_first_remaining) + 1)
|
|
1206
|
+
if _first_remaining is not None
|
|
1207
|
+
else (len(list(upstream_path)) + 1)
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
# ssa_to_subset tracks which original operands each SSA id covers.
|
|
1211
|
+
ssa_to_subset: dict[int, frozenset[int]] = {
|
|
1212
|
+
k: frozenset({k}) for k in range(num_ops)
|
|
1213
|
+
}
|
|
1214
|
+
ssa_ids: list[int] = list(range(num_ops))
|
|
1215
|
+
next_ssa = num_ops
|
|
1216
|
+
|
|
1217
|
+
# Bug B fix: build a SubgraphSymmetryOracle from the declared operand
|
|
1218
|
+
# symmetries so per-step input_groups / output_group / inner_group are
|
|
1219
|
+
# populated correctly instead of always being empty / None.
|
|
1220
|
+
# The oracle is built once here and queried per step below.
|
|
1221
|
+
oracle = None
|
|
1222
|
+
if per_op_symmetries is not None:
|
|
1223
|
+
orig_input_parts = getattr(upstream_info, "input_subscripts", None)
|
|
1224
|
+
orig_output = getattr(upstream_info, "output_subscript", "")
|
|
1225
|
+
if orig_input_parts is not None:
|
|
1226
|
+
_orig_parts_list = orig_input_parts.split(",")
|
|
1227
|
+
if len(_orig_parts_list) == num_ops:
|
|
1228
|
+
try:
|
|
1229
|
+
import numpy as _np
|
|
1230
|
+
|
|
1231
|
+
from flopscope._opt_einsum._subgraph_symmetry import (
|
|
1232
|
+
SubgraphSymmetryOracle,
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
# Build dummy operands with the right shapes, then alias
|
|
1236
|
+
# positions in the same identity-group to share object
|
|
1237
|
+
# identity. The oracle's Source-B (identical-operand
|
|
1238
|
+
# swap) and Source-C (coordinated relabel) π-generators
|
|
1239
|
+
# rely on object identity (``_dummy_ops[i] is _dummy_ops[j]``);
|
|
1240
|
+
# without aliasing, residual symmetries that come from
|
|
1241
|
+
# identical operands (e.g. A @ A → S₂ on output) would
|
|
1242
|
+
# silently be omitted from the per-step annotation.
|
|
1243
|
+
_dummy_ops: list = [
|
|
1244
|
+
_np.empty(tuple(size_dict[c] for c in part))
|
|
1245
|
+
for part in _orig_parts_list
|
|
1246
|
+
]
|
|
1247
|
+
if identity_pattern is not None:
|
|
1248
|
+
for _group in identity_pattern:
|
|
1249
|
+
_canonical = _dummy_ops[_group[0]]
|
|
1250
|
+
for _pos in _group[1:]:
|
|
1251
|
+
_dummy_ops[_pos] = _canonical
|
|
1252
|
+
|
|
1253
|
+
def _sym_to_group_list(sym: Any, subscript: str) -> list | None:
|
|
1254
|
+
"""Convert per_op_symmetry entry → oracle per_op_groups list.
|
|
1255
|
+
|
|
1256
|
+
Source A generators in ``_collect_pi_permutations`` require
|
|
1257
|
+
``group._labels`` to be populated (the function short-circuits
|
|
1258
|
+
on ``group._labels is None``). User-supplied groups created
|
|
1259
|
+
via ``SymmetryGroup.symmetric(axes=...)`` have ``axes`` set
|
|
1260
|
+
but ``_labels`` empty, so we synthesize labels here from the
|
|
1261
|
+
operand's subscript at the symmetry's axis positions.
|
|
1262
|
+
|
|
1263
|
+
We build a fresh ``SymmetryGroup`` so the user's object
|
|
1264
|
+
stays untouched (the oracle is per-call and these clones
|
|
1265
|
+
live only for its duration).
|
|
1266
|
+
"""
|
|
1267
|
+
if sym is None:
|
|
1268
|
+
return None
|
|
1269
|
+
if not isinstance(sym, SymmetryGroup):
|
|
1270
|
+
return None
|
|
1271
|
+
if sym._labels is not None:
|
|
1272
|
+
return [sym]
|
|
1273
|
+
if sym.axes is None:
|
|
1274
|
+
return [sym]
|
|
1275
|
+
try:
|
|
1276
|
+
labels = tuple(subscript[ax] for ax in sym.axes)
|
|
1277
|
+
except (IndexError, TypeError):
|
|
1278
|
+
return None
|
|
1279
|
+
clone = SymmetryGroup(*sym.generators, axes=sym.axes)
|
|
1280
|
+
clone._labels = labels
|
|
1281
|
+
return [clone]
|
|
1282
|
+
|
|
1283
|
+
oracle = SubgraphSymmetryOracle(
|
|
1284
|
+
operands=_dummy_ops,
|
|
1285
|
+
subscript_parts=_orig_parts_list,
|
|
1286
|
+
per_op_groups=[
|
|
1287
|
+
_sym_to_group_list(s, part)
|
|
1288
|
+
for s, part in zip(
|
|
1289
|
+
per_op_symmetries, _orig_parts_list, strict=False
|
|
1290
|
+
)
|
|
1291
|
+
],
|
|
1292
|
+
output_chars=orig_output,
|
|
1293
|
+
)
|
|
1294
|
+
except Exception:
|
|
1295
|
+
oracle = None
|
|
1296
|
+
|
|
1297
|
+
# SSA current-subsets list for oracle queries — parallel to ssa_ids list.
|
|
1298
|
+
# Each entry is the frozenset of original operand indices covered by the
|
|
1299
|
+
# corresponding current operand. Starts as singletons.
|
|
1300
|
+
current_oracle_subsets: list[frozenset[int]] = [
|
|
1301
|
+
frozenset({k}) for k in range(num_ops)
|
|
1302
|
+
]
|
|
1303
|
+
|
|
1304
|
+
for step_idx, entry in enumerate(upstream_info.contraction_list):
|
|
1305
|
+
idx_removed = entry[1] # frozenset of label chars removed (inner product)
|
|
1306
|
+
einsum_str = entry[2] # e.g. "jk,ij->ik"
|
|
1307
|
+
do_blas = entry[4] # BLAS classification string or False
|
|
1308
|
+
|
|
1309
|
+
# The original path indices for this step (pre-sort, from upstream_path).
|
|
1310
|
+
original_path_tuple: tuple[int, ...] = tuple(upstream_path[step_idx])
|
|
1311
|
+
|
|
1312
|
+
if "->" in einsum_str:
|
|
1313
|
+
lhs, rhs = einsum_str.split("->", 1)
|
|
1314
|
+
else:
|
|
1315
|
+
lhs, rhs = einsum_str, ""
|
|
1316
|
+
|
|
1317
|
+
lhs_parts = lhs.split(",")
|
|
1318
|
+
num_terms = len(lhs_parts)
|
|
1319
|
+
|
|
1320
|
+
# Reconstruct idx_contraction (set of all labels touched) from lhs
|
|
1321
|
+
idx_contraction: frozenset[str] = frozenset(
|
|
1322
|
+
c for part in lhs_parts for c in part
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
inner = bool(idx_removed)
|
|
1326
|
+
|
|
1327
|
+
input_shapes_for_step: list[tuple[int, ...]] = [
|
|
1328
|
+
tuple(size_dict[c] for c in part) for part in lhs_parts
|
|
1329
|
+
]
|
|
1330
|
+
output_shape_for_step: tuple[int, ...] = tuple(size_dict[c] for c in rhs)
|
|
1331
|
+
|
|
1332
|
+
# Bug B fix: query the oracle for per-step symmetry groups BEFORE computing
|
|
1333
|
+
# cost so that propagated intermediate symmetries reduce the per-step cost.
|
|
1334
|
+
# The oracle uses current_oracle_subsets (not yet updated for this step)
|
|
1335
|
+
# so querying before the SSA merge gives us the current inputs' symmetries.
|
|
1336
|
+
step_input_groups: list = []
|
|
1337
|
+
step_output_group: object | None = None
|
|
1338
|
+
step_inner_group: object | None = None
|
|
1339
|
+
|
|
1340
|
+
# Reconstruct merged_subset by tracking which original operands each
|
|
1341
|
+
# SSA id covers. The path gives us the positions to contract.
|
|
1342
|
+
contract_positions = tuple(sorted(original_path_tuple, reverse=True))
|
|
1343
|
+
new_merged_subset: frozenset[int] = frozenset()
|
|
1344
|
+
for ci in contract_positions:
|
|
1345
|
+
if ci < len(ssa_ids):
|
|
1346
|
+
new_merged_subset = new_merged_subset | ssa_to_subset[ssa_ids[ci]]
|
|
1347
|
+
|
|
1348
|
+
if oracle is not None:
|
|
1349
|
+
try:
|
|
1350
|
+
# Gather which oracle-tracked subsets map to each lhs input.
|
|
1351
|
+
# opt_einsum pops positions highest-to-lowest (contract_positions
|
|
1352
|
+
# is sorted descending), so the lhs subscript order matches
|
|
1353
|
+
# the descending-position order of the path entry.
|
|
1354
|
+
step_input_subsets = [
|
|
1355
|
+
current_oracle_subsets[pos]
|
|
1356
|
+
for pos in sorted(original_path_tuple, reverse=True)
|
|
1357
|
+
if pos < len(current_oracle_subsets)
|
|
1358
|
+
]
|
|
1359
|
+
for inp_subset in step_input_subsets:
|
|
1360
|
+
ss = oracle.sym(inp_subset)
|
|
1361
|
+
step_input_groups.append(ss.output) # V-side group for this input
|
|
1362
|
+
# For output_group and inner_group, query the merged subset.
|
|
1363
|
+
merged_ss = oracle.sym(new_merged_subset)
|
|
1364
|
+
step_output_group = merged_ss.output
|
|
1365
|
+
step_inner_group = merged_ss.inner
|
|
1366
|
+
except Exception:
|
|
1367
|
+
step_input_groups = []
|
|
1368
|
+
step_output_group = None
|
|
1369
|
+
step_inner_group = None
|
|
1370
|
+
|
|
1371
|
+
cost = symmetric_flop_count(
|
|
1372
|
+
idx_contraction,
|
|
1373
|
+
inner,
|
|
1374
|
+
num_terms,
|
|
1375
|
+
size_dict,
|
|
1376
|
+
input_subscripts=lhs_parts,
|
|
1377
|
+
output_subscript=rhs,
|
|
1378
|
+
input_shapes=input_shapes_for_step,
|
|
1379
|
+
per_input_groups=step_input_groups if step_input_groups else None,
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
# Dense cost: what this step would cost without any symmetry reduction.
|
|
1383
|
+
step_dense_flop_cost = helpers.flop_count(
|
|
1384
|
+
idx_contraction,
|
|
1385
|
+
inner,
|
|
1386
|
+
num_terms,
|
|
1387
|
+
size_dict,
|
|
1388
|
+
input_subscripts=lhs_parts,
|
|
1389
|
+
output_subscript=rhs,
|
|
1390
|
+
input_shapes=input_shapes_for_step,
|
|
1391
|
+
)
|
|
1392
|
+
# Fraction of dense cost saved by symmetry (0.0 when no symmetry or
|
|
1393
|
+
# when the accumulation model costs more than the dense baseline due to
|
|
1394
|
+
# FMA vs. flop_count differences on this branch).
|
|
1395
|
+
step_symmetry_savings = (
|
|
1396
|
+
max(0.0, 1.0 - cost / step_dense_flop_cost)
|
|
1397
|
+
if step_dense_flop_cost > 0
|
|
1398
|
+
else 0.0
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
if output_shape_for_step:
|
|
1402
|
+
largest_intermediate = max(
|
|
1403
|
+
largest_intermediate, prod(output_shape_for_step)
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
# Complete the SSA merge (popping positions into next_ssa).
|
|
1407
|
+
for ci in contract_positions:
|
|
1408
|
+
if ci < len(ssa_ids):
|
|
1409
|
+
ssa_ids.pop(ci)
|
|
1410
|
+
ssa_to_subset[next_ssa] = new_merged_subset
|
|
1411
|
+
ssa_ids.append(next_ssa)
|
|
1412
|
+
next_ssa += 1
|
|
1413
|
+
|
|
1414
|
+
# Update current_oracle_subsets to mirror the SSA merge above.
|
|
1415
|
+
# Guard against out-of-range indices the same way the ssa_ids loop does.
|
|
1416
|
+
_oracle_contract_positions = tuple(
|
|
1417
|
+
pos
|
|
1418
|
+
for pos in sorted(original_path_tuple, reverse=True)
|
|
1419
|
+
if pos < len(current_oracle_subsets)
|
|
1420
|
+
)
|
|
1421
|
+
if _oracle_contract_positions:
|
|
1422
|
+
merged_oracle_subset: frozenset[int] = frozenset().union(
|
|
1423
|
+
*(current_oracle_subsets[pos] for pos in _oracle_contract_positions)
|
|
1424
|
+
)
|
|
1425
|
+
for pos in _oracle_contract_positions:
|
|
1426
|
+
current_oracle_subsets.pop(pos)
|
|
1427
|
+
current_oracle_subsets.append(merged_oracle_subset)
|
|
1428
|
+
else:
|
|
1429
|
+
current_oracle_subsets.append(frozenset())
|
|
1430
|
+
|
|
1431
|
+
steps_out.append(
|
|
1432
|
+
StepInfo(
|
|
1433
|
+
subscript=einsum_str,
|
|
1434
|
+
flop_cost=cost,
|
|
1435
|
+
input_shapes=input_shapes_for_step,
|
|
1436
|
+
output_shape=output_shape_for_step,
|
|
1437
|
+
blas_type=do_blas,
|
|
1438
|
+
path_indices=original_path_tuple,
|
|
1439
|
+
merged_subset=new_merged_subset,
|
|
1440
|
+
# Diagnostic fields: dense baseline and symmetry savings.
|
|
1441
|
+
dense_flop_cost=step_dense_flop_cost,
|
|
1442
|
+
symmetry_savings=step_symmetry_savings,
|
|
1443
|
+
# Bug B fix: symmetry groups from oracle (empty list / None when
|
|
1444
|
+
# per_op_symmetries was not provided or oracle build failed).
|
|
1445
|
+
input_groups=step_input_groups,
|
|
1446
|
+
output_group=step_output_group,
|
|
1447
|
+
inner_group=step_inner_group,
|
|
1448
|
+
)
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
optimized_cost = sum(s.flop_cost for s in steps_out)
|
|
1452
|
+
|
|
1453
|
+
# Bug A fix: naive_cost uses the same α/M model as the per-step dense_flop_cost
|
|
1454
|
+
# (helpers.flop_count, no symmetry), so header "Savings" and per-step "savings"
|
|
1455
|
+
# columns are computed from the same model. The old approach used
|
|
1456
|
+
# helpers.flop_count over ALL labels as if they were contracted in one shot,
|
|
1457
|
+
# which can be a very different number than the sum of per-step dense costs.
|
|
1458
|
+
naive_cost = sum(s.dense_flop_cost for s in steps_out)
|
|
1459
|
+
|
|
1460
|
+
speedup = (naive_cost / optimized_cost) if optimized_cost > 0 else 1.0
|
|
1461
|
+
|
|
1462
|
+
return PathInfo(
|
|
1463
|
+
path=list(upstream_path),
|
|
1464
|
+
steps=steps_out,
|
|
1465
|
+
naive_cost=naive_cost,
|
|
1466
|
+
optimized_cost=optimized_cost,
|
|
1467
|
+
largest_intermediate=largest_intermediate,
|
|
1468
|
+
speedup=speedup,
|
|
1469
|
+
input_subscripts=getattr(upstream_info, "input_subscripts", ""),
|
|
1470
|
+
output_subscript=getattr(upstream_info, "output_subscript", ""),
|
|
1471
|
+
size_dict=dict(size_dict),
|
|
1472
|
+
optimizer_used=optimizer_used,
|
|
1473
|
+
contraction_list=list(upstream_info.contraction_list),
|
|
1474
|
+
scale_list=list(getattr(upstream_info, "scale_list", [])),
|
|
1475
|
+
size_list=list(getattr(upstream_info, "size_list", [])),
|
|
1476
|
+
_oe_naive_cost=naive_cost,
|
|
1477
|
+
_oe_opt_cost=optimized_cost,
|
|
1478
|
+
)
|