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
flopscope/_einsum.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""Einsum with analytical FLOP counting, symmetry detection, and path optimization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import numpy as _np
|
|
10
|
+
|
|
11
|
+
from flopscope._budget import _call_numpy, _counted_wrapper
|
|
12
|
+
from flopscope._config import get_setting
|
|
13
|
+
from flopscope._ndarray import FlopscopeArray, _to_base_ndarray
|
|
14
|
+
from flopscope._perm_group import SymmetryGroup
|
|
15
|
+
from flopscope._pointwise import _prepare_symmetric_out, _validate_result_symmetry
|
|
16
|
+
from flopscope._symmetric import SymmetricTensor
|
|
17
|
+
from flopscope._symmetry_utils import normalize_symmetry_input, validate_symmetry_group
|
|
18
|
+
from flopscope._validation import maybe_check_nan_inf, require_budget
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _identity_pattern(operands):
|
|
22
|
+
"""Build a hashable pattern of which operands are the same Python object.
|
|
23
|
+
|
|
24
|
+
Returns None if all operands are distinct objects (common case).
|
|
25
|
+
Otherwise returns a tuple of tuples, where each inner tuple lists
|
|
26
|
+
positions sharing the same object identity (only groups of size >= 2).
|
|
27
|
+
|
|
28
|
+
This mirrors the identical_operand_groups logic in _build_bipartite.
|
|
29
|
+
"""
|
|
30
|
+
id_to_positions: dict[int, list[int]] = {}
|
|
31
|
+
for idx, op in enumerate(operands):
|
|
32
|
+
id_to_positions.setdefault(id(op), []).append(idx)
|
|
33
|
+
groups = tuple(
|
|
34
|
+
tuple(positions)
|
|
35
|
+
for positions in id_to_positions.values()
|
|
36
|
+
if len(positions) >= 2
|
|
37
|
+
)
|
|
38
|
+
return groups if groups else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _make_path_cache(maxsize):
|
|
42
|
+
"""Create a new lru_cache-wrapped path computation function.
|
|
43
|
+
|
|
44
|
+
The cache key includes subscripts, shapes, optimizer, per_op_symmetries,
|
|
45
|
+
and identity_pattern. Re-runs with the same inputs return the cached path.
|
|
46
|
+
|
|
47
|
+
The key also includes ``per_op_symmetries`` (a tuple of per-operand
|
|
48
|
+
SymmetryGroup-or-None, canonicalized as a hashable fingerprint) and
|
|
49
|
+
``identity_pattern`` so that symmetric operands produce a distinct cache
|
|
50
|
+
entry from dense operands with the same subscripts and shapes. When
|
|
51
|
+
symmetry-aware path search is enabled the chosen path may differ; without
|
|
52
|
+
this the dense-optimal path would silently be reused for symmetric inputs.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@functools.lru_cache(maxsize=maxsize)
|
|
56
|
+
def _compute(
|
|
57
|
+
subscripts,
|
|
58
|
+
shapes,
|
|
59
|
+
optimize,
|
|
60
|
+
per_op_symmetries,
|
|
61
|
+
identity_pattern,
|
|
62
|
+
):
|
|
63
|
+
from flopscope._opt_einsum import contract_path as _contract_path
|
|
64
|
+
|
|
65
|
+
_path, path_info = _contract_path(
|
|
66
|
+
subscripts,
|
|
67
|
+
*shapes,
|
|
68
|
+
shapes=True,
|
|
69
|
+
optimize=optimize if not isinstance(optimize, tuple) else list(optimize),
|
|
70
|
+
)
|
|
71
|
+
return path_info
|
|
72
|
+
|
|
73
|
+
return _compute
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_path_cache = _make_path_cache(4096)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _rebuild_einsum_cache():
|
|
80
|
+
"""Rebuild the path cache with the current configured maxsize."""
|
|
81
|
+
global _path_cache
|
|
82
|
+
_path_cache = _make_path_cache(int(get_setting("einsum_path_cache_size"))) # type: ignore[arg-type]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def clear_einsum_cache():
|
|
86
|
+
"""Clear the einsum path cache.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
None
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
None
|
|
95
|
+
Discards all cached contraction paths.
|
|
96
|
+
|
|
97
|
+
Notes
|
|
98
|
+
-----
|
|
99
|
+
Discards all cached contraction paths. Subsequent ``einsum()`` and
|
|
100
|
+
``einsum_path()`` calls will recompute paths from scratch.
|
|
101
|
+
|
|
102
|
+
Examples
|
|
103
|
+
--------
|
|
104
|
+
>>> import flopscope.numpy as fnp
|
|
105
|
+
>>> fnp.clear_einsum_cache()
|
|
106
|
+
"""
|
|
107
|
+
_path_cache.cache_clear()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def einsum_cache_info():
|
|
111
|
+
"""Return einsum path cache statistics.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
None
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
object
|
|
120
|
+
The standard ``functools.lru_cache`` statistics tuple with ``hits``,
|
|
121
|
+
``misses``, ``maxsize``, and ``currsize`` fields.
|
|
122
|
+
|
|
123
|
+
Examples
|
|
124
|
+
--------
|
|
125
|
+
>>> import flopscope.numpy as fnp
|
|
126
|
+
>>> info = fnp.einsum_cache_info()
|
|
127
|
+
>>> total = info.hits + info.misses
|
|
128
|
+
>>> rate = info.hits / max(total, 1)
|
|
129
|
+
"""
|
|
130
|
+
return _path_cache.cache_info()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _execute_pairwise(path_info, operands: list):
|
|
134
|
+
"""Execute pairwise contractions according to the optimized path."""
|
|
135
|
+
ops = list(operands)
|
|
136
|
+
for contract_inds, step in zip(path_info.path, path_info.steps, strict=False):
|
|
137
|
+
# Pop operands in reverse sorted order (same as opt_einsum convention)
|
|
138
|
+
inds = sorted(contract_inds, reverse=True)
|
|
139
|
+
tensors = [ops.pop(i) for i in inds]
|
|
140
|
+
result = _call_numpy(
|
|
141
|
+
_np.einsum, step.subscript, *[_to_base_ndarray(t) for t in tensors]
|
|
142
|
+
)
|
|
143
|
+
ops.append(result)
|
|
144
|
+
return ops[0]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_LARGE_K_THRESHOLD = 8
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _resolve_optimize_for_k(optimize, k: int):
|
|
151
|
+
"""Auto-downgrade 'auto' to 'greedy' for k >= 8 to avoid optimal/B&B
|
|
152
|
+
cold-call latency on large operand counts. Explicit user choices
|
|
153
|
+
(optimal/branch/dp/etc.) are honored verbatim. See spec §10.
|
|
154
|
+
"""
|
|
155
|
+
if optimize == "auto" and k >= _LARGE_K_THRESHOLD:
|
|
156
|
+
return "greedy"
|
|
157
|
+
return optimize
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _normalize_optimize(optimize):
|
|
161
|
+
if optimize is False:
|
|
162
|
+
return "auto"
|
|
163
|
+
if isinstance(optimize, list):
|
|
164
|
+
return tuple(tuple(t) for t in optimize)
|
|
165
|
+
return optimize
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_einsum_parts(subscripts: str, operands):
|
|
169
|
+
from flopscope._opt_einsum import parse_einsum_input
|
|
170
|
+
|
|
171
|
+
input_subscripts, output_subscript, _ = parse_einsum_input((subscripts, *operands))
|
|
172
|
+
canonical_subscripts = f"{input_subscripts}->{output_subscript}"
|
|
173
|
+
return canonical_subscripts, input_subscripts.split(","), output_subscript
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_path_info(
|
|
177
|
+
subscripts: str,
|
|
178
|
+
operands,
|
|
179
|
+
optimize,
|
|
180
|
+
*,
|
|
181
|
+
per_op_symmetries=None,
|
|
182
|
+
identity_pattern=None,
|
|
183
|
+
):
|
|
184
|
+
canonical_subscripts, input_parts, output_subscript = _parse_einsum_parts(
|
|
185
|
+
subscripts,
|
|
186
|
+
operands,
|
|
187
|
+
)
|
|
188
|
+
shapes = tuple(tuple(op.shape) for op in operands)
|
|
189
|
+
|
|
190
|
+
# Build a hashable symmetry key for the cache. Each entry is either None
|
|
191
|
+
# (dense operand) or the canonical fingerprint of a SymmetryGroup so that
|
|
192
|
+
# symmetric and dense operands with identical subscripts/shapes get distinct
|
|
193
|
+
# cache slots. This prevents a dense-optimal path from being silently
|
|
194
|
+
# reused when symmetry-aware path search is later enabled.
|
|
195
|
+
if per_op_symmetries is None:
|
|
196
|
+
from flopscope._accumulation._public import _per_op_symmetries as _extract_syms
|
|
197
|
+
|
|
198
|
+
per_op_symmetries = _extract_syms(operands)
|
|
199
|
+
syms_key = tuple(per_op_symmetries)
|
|
200
|
+
|
|
201
|
+
if identity_pattern is None:
|
|
202
|
+
from flopscope._accumulation._public import _identity_pattern as _extract_id
|
|
203
|
+
|
|
204
|
+
identity_pattern = _extract_id(operands)
|
|
205
|
+
|
|
206
|
+
effective_optimize = _resolve_optimize_for_k(optimize, k=len(operands))
|
|
207
|
+
path_info = _path_cache(
|
|
208
|
+
canonical_subscripts,
|
|
209
|
+
shapes,
|
|
210
|
+
_normalize_optimize(effective_optimize),
|
|
211
|
+
syms_key,
|
|
212
|
+
identity_pattern,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Bug B fix: if any operand has declared symmetry OR multiple operand
|
|
216
|
+
# positions alias to the same array (identity_pattern), rebuild path_info
|
|
217
|
+
# through the SubgraphSymmetryOracle so that per-step input_groups /
|
|
218
|
+
# output_group / inner_group reflect the true residual symmetry of each
|
|
219
|
+
# intermediate. Without this rebuild, Source-A (declared groups),
|
|
220
|
+
# Source-B (identical-operand swap), and Source-C (coordinated relabel)
|
|
221
|
+
# π-generators never reach the renderer.
|
|
222
|
+
#
|
|
223
|
+
# _path_cache returns a shared cached object that must not be mutated;
|
|
224
|
+
# build_path_info returns a fresh PathInfo each time. The rebuild is
|
|
225
|
+
# skipped when there's no symmetry signal at all (the common case) to
|
|
226
|
+
# keep the fast path.
|
|
227
|
+
_has_identity_alias = bool(identity_pattern) and any(
|
|
228
|
+
len(group) > 1 for group in identity_pattern
|
|
229
|
+
)
|
|
230
|
+
if any(s is not None for s in per_op_symmetries) or _has_identity_alias:
|
|
231
|
+
import numpy as _np_tmp
|
|
232
|
+
import opt_einsum as _oe
|
|
233
|
+
|
|
234
|
+
from flopscope._opt_einsum._contract import build_path_info as _bpi
|
|
235
|
+
|
|
236
|
+
# Build dummy operands with the correct shapes, then alias positions
|
|
237
|
+
# listed in the same identity-group to share object identity — this
|
|
238
|
+
# is the signal the oracle uses to fire Source-B (identical-operand
|
|
239
|
+
# swap) and Source-C (coordinated axis relabel) generators.
|
|
240
|
+
_dummy_ops: list = [_np_tmp.empty(sh) for sh in shapes]
|
|
241
|
+
if identity_pattern is not None:
|
|
242
|
+
for group in identity_pattern:
|
|
243
|
+
canonical = _dummy_ops[group[0]]
|
|
244
|
+
for pos in group[1:]:
|
|
245
|
+
_dummy_ops[pos] = canonical
|
|
246
|
+
|
|
247
|
+
_norm_optimize = _normalize_optimize(effective_optimize)
|
|
248
|
+
if isinstance(_norm_optimize, tuple):
|
|
249
|
+
_norm_optimize = list(_norm_optimize)
|
|
250
|
+
_upstream_path, _upstream_info = _oe.contract_path(
|
|
251
|
+
canonical_subscripts,
|
|
252
|
+
*_dummy_ops,
|
|
253
|
+
optimize=_norm_optimize, # type: ignore[arg-type]
|
|
254
|
+
)
|
|
255
|
+
# Carry the optimizer label through the rebuild so the renderer's
|
|
256
|
+
# "Optimizer:" pill stays populated. effective_optimize is whatever
|
|
257
|
+
# was actually used for path search; coerce to a string label.
|
|
258
|
+
if isinstance(effective_optimize, str):
|
|
259
|
+
_optimizer_label = effective_optimize
|
|
260
|
+
else:
|
|
261
|
+
_optimizer_label = getattr(_upstream_info, "_path_type", "") or ""
|
|
262
|
+
path_info = _bpi(
|
|
263
|
+
_upstream_path,
|
|
264
|
+
_upstream_info,
|
|
265
|
+
size_dict=_upstream_info.size_dict,
|
|
266
|
+
optimizer_used=_optimizer_label,
|
|
267
|
+
per_op_symmetries=per_op_symmetries,
|
|
268
|
+
identity_pattern=identity_pattern,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return canonical_subscripts, input_parts, output_subscript, shapes, path_info
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _relabel_group_to_output(
|
|
275
|
+
group, source_labels: tuple[str, ...], output_subscript: str
|
|
276
|
+
):
|
|
277
|
+
if group is None or not source_labels or not output_subscript:
|
|
278
|
+
return None
|
|
279
|
+
output_positions = {label: idx for idx, label in enumerate(output_subscript)}
|
|
280
|
+
try:
|
|
281
|
+
source_positions = tuple(output_positions[label] for label in source_labels)
|
|
282
|
+
except KeyError:
|
|
283
|
+
return None
|
|
284
|
+
if len(set(source_positions)) != len(source_positions):
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
order = tuple(
|
|
288
|
+
sorted(range(len(source_positions)), key=source_positions.__getitem__)
|
|
289
|
+
)
|
|
290
|
+
axes = tuple(source_positions[idx] for idx in order)
|
|
291
|
+
source_to_sorted = {
|
|
292
|
+
source_idx: sorted_idx for sorted_idx, source_idx in enumerate(order)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
from flopscope._perm_group import _PermutationCompat as Permutation
|
|
296
|
+
|
|
297
|
+
generators = []
|
|
298
|
+
for gen in group.generators:
|
|
299
|
+
generators.append(
|
|
300
|
+
Permutation(
|
|
301
|
+
[source_to_sorted[gen.array_form[source_idx]] for source_idx in order]
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
remapped = SymmetryGroup(*generators, axes=axes)
|
|
306
|
+
return validate_symmetry_group(remapped, ndim=len(output_subscript))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _infer_pathless_output_symmetry(operands, input_parts, output_subscript: str):
|
|
310
|
+
if len(operands) != 1:
|
|
311
|
+
return None
|
|
312
|
+
operand = operands[0]
|
|
313
|
+
if not isinstance(operand, SymmetricTensor) or operand.symmetry is None:
|
|
314
|
+
return None
|
|
315
|
+
group = operand.symmetry
|
|
316
|
+
operand_subscript = input_parts[0]
|
|
317
|
+
operand_rank = len(operand_subscript)
|
|
318
|
+
|
|
319
|
+
# Detect axes that get summed out (label appears in operand but not output).
|
|
320
|
+
summed_axes = tuple(
|
|
321
|
+
i for i, label in enumerate(operand_subscript) if label not in output_subscript
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if summed_axes:
|
|
325
|
+
# Compute the setwise-stabilizer of the summed axes inside the group,
|
|
326
|
+
# then project onto the surviving axes via the existing reduce_group
|
|
327
|
+
# helper (which composes setwise_stabilizer + restrict + axis remap
|
|
328
|
+
# into one numpy-reduction-style call). This is the
|
|
329
|
+
# stabilizer-restriction operation Wilson's review asked for.
|
|
330
|
+
from flopscope._symmetry_utils import reduce_group
|
|
331
|
+
|
|
332
|
+
reduced_group = reduce_group(group, ndim=operand_rank, axis=summed_axes)
|
|
333
|
+
if reduced_group is None:
|
|
334
|
+
return None
|
|
335
|
+
# reduce_group's keepdims=False shifts surviving operand axes to
|
|
336
|
+
# contiguous 0..k-1 positions in the reduced-tensor frame. Recover
|
|
337
|
+
# operand-subscript labels for the reduced group's axes so that the
|
|
338
|
+
# subsequent _relabel_group_to_output call can map them to the
|
|
339
|
+
# einsum's output_subscript positions (which may further reorder).
|
|
340
|
+
kept_operand_axes = [
|
|
341
|
+
i for i in range(operand_rank) if i not in set(summed_axes)
|
|
342
|
+
]
|
|
343
|
+
new_to_operand_axis = dict(enumerate(kept_operand_axes))
|
|
344
|
+
reduced_axes = (
|
|
345
|
+
reduced_group.axes
|
|
346
|
+
if reduced_group.axes is not None
|
|
347
|
+
else tuple(range(reduced_group.degree))
|
|
348
|
+
)
|
|
349
|
+
source_labels = tuple(
|
|
350
|
+
operand_subscript[new_to_operand_axis[ax]] for ax in reduced_axes
|
|
351
|
+
)
|
|
352
|
+
return _relabel_group_to_output(reduced_group, source_labels, output_subscript)
|
|
353
|
+
|
|
354
|
+
# No reduction — surviving labels all appear in output; existing direct path.
|
|
355
|
+
axes = group.axes if group.axes is not None else tuple(range(group.degree))
|
|
356
|
+
source_labels = tuple(operand_subscript[axis] for axis in axes)
|
|
357
|
+
return _relabel_group_to_output(group, source_labels, output_subscript)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _infer_multi_operand_output_symmetry(path_info, output_subscript: str):
|
|
361
|
+
"""Infer the output tensor's symmetry from the path walker's last step.
|
|
362
|
+
|
|
363
|
+
Returns the SymmetryGroup acting on the einsum's output_subscript labels,
|
|
364
|
+
or None if no symmetry was derived or relabel fails.
|
|
365
|
+
|
|
366
|
+
The path walker's oracle stores `output_group` on each StepInfo with axes
|
|
367
|
+
indexing the *step's* output subscript. We must relabel those axes to
|
|
368
|
+
positions in the *einsum's* output_subscript, which may differ (opt_einsum
|
|
369
|
+
can permute labels for BLAS-friendly orientation).
|
|
370
|
+
"""
|
|
371
|
+
if path_info is None:
|
|
372
|
+
return None
|
|
373
|
+
steps = getattr(path_info, "steps", None)
|
|
374
|
+
if not steps:
|
|
375
|
+
return None
|
|
376
|
+
last = steps[-1]
|
|
377
|
+
group = getattr(last, "output_group", None)
|
|
378
|
+
if group is None:
|
|
379
|
+
return None
|
|
380
|
+
if not output_subscript:
|
|
381
|
+
return None
|
|
382
|
+
# Derive the step's output labels from its subscript string ("lhs->rhs").
|
|
383
|
+
step_subscript = getattr(last, "subscript", "")
|
|
384
|
+
if "->" not in step_subscript:
|
|
385
|
+
return None
|
|
386
|
+
_, step_out = step_subscript.split("->", 1)
|
|
387
|
+
if not step_out:
|
|
388
|
+
return None
|
|
389
|
+
# group.axes are positions in step_out; map each to its label, then relabel
|
|
390
|
+
# to positions in output_subscript.
|
|
391
|
+
axes = group.axes if group.axes is not None else tuple(range(group.degree))
|
|
392
|
+
try:
|
|
393
|
+
source_labels = tuple(step_out[ax] for ax in axes)
|
|
394
|
+
except IndexError:
|
|
395
|
+
return None
|
|
396
|
+
return _relabel_group_to_output(group, source_labels, output_subscript)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _resolve_output_symmetry(
|
|
400
|
+
*,
|
|
401
|
+
symmetry,
|
|
402
|
+
operands,
|
|
403
|
+
input_parts,
|
|
404
|
+
output_subscript: str,
|
|
405
|
+
path_info=None,
|
|
406
|
+
):
|
|
407
|
+
if symmetry is not None:
|
|
408
|
+
return normalize_symmetry_input(symmetry, ndim=len(output_subscript))
|
|
409
|
+
if len(operands) == 1:
|
|
410
|
+
return _infer_pathless_output_symmetry(operands, input_parts, output_subscript)
|
|
411
|
+
return _infer_multi_operand_output_symmetry(path_info, output_subscript)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@dataclass(frozen=True, slots=True)
|
|
415
|
+
class _CostInfo:
|
|
416
|
+
"""Output of :func:`_resolve_cost_and_output_symmetry`.
|
|
417
|
+
|
|
418
|
+
Carries everything a bilinear wrapper needs to charge budget and wrap
|
|
419
|
+
its result: the symmetry-aware accumulation cost, the inferred output
|
|
420
|
+
symmetry (or None), the canonical einsum subscript string, the shapes
|
|
421
|
+
tuple, and the full path info (reserved for future use).
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
accumulation: Any # flopscope._accumulation._cost.AccumulationCost
|
|
425
|
+
output_symmetry: SymmetryGroup | None
|
|
426
|
+
canonical_subscripts: str
|
|
427
|
+
input_parts: tuple[str, ...]
|
|
428
|
+
output_subscript: str
|
|
429
|
+
shapes: tuple[tuple[int, ...], ...]
|
|
430
|
+
path_info: Any # FlopscopePathInfo
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _resolve_cost_and_output_symmetry(
|
|
434
|
+
subscripts: str,
|
|
435
|
+
*operands: Any,
|
|
436
|
+
optimize: str | bool | list[Any] = "auto",
|
|
437
|
+
) -> _CostInfo:
|
|
438
|
+
"""Run path-find + accumulation-cost + output-symmetry inference.
|
|
439
|
+
|
|
440
|
+
Does NOT execute compute; does NOT charge budget. Used by the bilinear
|
|
441
|
+
wrappers (matmul/dot/outer/inner/tensordot/vdot) to share einsum's
|
|
442
|
+
cost+symmetry-inference machinery while keeping their native BLAS-fast
|
|
443
|
+
compute paths and friendly op-names.
|
|
444
|
+
|
|
445
|
+
Parameters
|
|
446
|
+
----------
|
|
447
|
+
subscripts : str
|
|
448
|
+
Einsum subscript string (e.g. ``"ij,jk->ik"``).
|
|
449
|
+
*operands
|
|
450
|
+
The operands as the caller sees them (raw ndarray, FlopscopeArray,
|
|
451
|
+
or SymmetricTensor — all handled).
|
|
452
|
+
optimize : str | bool | list, optional
|
|
453
|
+
Path optimizer; defaults to ``"auto"``.
|
|
454
|
+
|
|
455
|
+
Returns
|
|
456
|
+
-------
|
|
457
|
+
_CostInfo
|
|
458
|
+
Dataclass with ``accumulation``, ``output_symmetry``,
|
|
459
|
+
``canonical_subscripts``, ``input_parts``, ``output_subscript``,
|
|
460
|
+
``shapes``, ``path_info``.
|
|
461
|
+
"""
|
|
462
|
+
canonical_subscripts, input_parts, output_subscript, shapes, path_info = (
|
|
463
|
+
_get_path_info(subscripts, operands, optimize)
|
|
464
|
+
)
|
|
465
|
+
accumulation_cost = _get_accumulation_cost(
|
|
466
|
+
canonical_subscripts=canonical_subscripts,
|
|
467
|
+
input_parts=tuple(input_parts),
|
|
468
|
+
output_subscript=output_subscript,
|
|
469
|
+
shapes=shapes,
|
|
470
|
+
operands=tuple(operands),
|
|
471
|
+
)
|
|
472
|
+
from flopscope._accumulation._path_info import FlopscopePathInfo
|
|
473
|
+
|
|
474
|
+
path_info = FlopscopePathInfo.from_inner(
|
|
475
|
+
inner=path_info,
|
|
476
|
+
accumulation=accumulation_cost,
|
|
477
|
+
)
|
|
478
|
+
output_symmetry = _resolve_output_symmetry(
|
|
479
|
+
symmetry=None,
|
|
480
|
+
operands=operands,
|
|
481
|
+
input_parts=input_parts,
|
|
482
|
+
output_subscript=output_subscript,
|
|
483
|
+
path_info=path_info,
|
|
484
|
+
)
|
|
485
|
+
return _CostInfo(
|
|
486
|
+
accumulation=accumulation_cost,
|
|
487
|
+
output_symmetry=output_symmetry,
|
|
488
|
+
canonical_subscripts=canonical_subscripts,
|
|
489
|
+
input_parts=tuple(input_parts),
|
|
490
|
+
output_subscript=output_subscript,
|
|
491
|
+
shapes=tuple(shapes),
|
|
492
|
+
path_info=path_info,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@_counted_wrapper
|
|
497
|
+
def einsum(
|
|
498
|
+
subscripts: str,
|
|
499
|
+
*operands: _np.ndarray,
|
|
500
|
+
out: Any = None,
|
|
501
|
+
optimize: str | bool | list[Any] = "auto",
|
|
502
|
+
symmetry: Any = None,
|
|
503
|
+
**kwargs: Any,
|
|
504
|
+
) -> FlopscopeArray:
|
|
505
|
+
"""Evaluate Einstein summation with FLOP counting and optional path optimization.
|
|
506
|
+
|
|
507
|
+
Wraps ``numpy.einsum`` with analytical FLOP cost computation and
|
|
508
|
+
optional symmetry savings. If any input is a ``SymmetricTensor``,
|
|
509
|
+
the cost is automatically reduced. If ``symmetry`` is provided and the output passes validation, a ``SymmetricTensor`` is returned.
|
|
510
|
+
|
|
511
|
+
All contractions go through opt_einsum's ``contract_path`` to find an
|
|
512
|
+
optimal pairwise decomposition. The charged FLOP cost comes from the
|
|
513
|
+
path-independent symmetry-aware accumulation total
|
|
514
|
+
(``path_info.accumulation.total``); per-step ``flop_count`` values on
|
|
515
|
+
each ``StepInfo`` use flopscope's FMA=2 textbook convention throughout.
|
|
516
|
+
|
|
517
|
+
Contraction paths are cached in a module-level LRU cache keyed on
|
|
518
|
+
(subscripts, shapes, optimizer, per_op_symmetries, identity_pattern).
|
|
519
|
+
Repeated calls with the same inputs skip path recomputation entirely.
|
|
520
|
+
See ``clear_einsum_cache()`` and ``einsum_cache_info()``.
|
|
521
|
+
|
|
522
|
+
Parameters
|
|
523
|
+
----------
|
|
524
|
+
subscripts : str
|
|
525
|
+
Einstein summation subscript string (e.g., ``'ij,jk->ik'``).
|
|
526
|
+
*operands : numpy.ndarray
|
|
527
|
+
Input arrays. ``SymmetricTensor`` inputs are detected automatically
|
|
528
|
+
for cost savings.
|
|
529
|
+
optimize : str, bool, or list of tuple, optional
|
|
530
|
+
Contraction path strategy. Default ``'auto'``.
|
|
531
|
+
|
|
532
|
+
- ``'auto'``, ``'greedy'``, ``'optimal'``, ``'dp'``, etc.:
|
|
533
|
+
Use the named algorithm to find the best path.
|
|
534
|
+
- A list of int-tuples (e.g. ``[(1, 2), (0, 1)]``): use this
|
|
535
|
+
explicit contraction path. Obtain one from ``fnp.einsum_path()``
|
|
536
|
+
or construct manually. Each tuple names the operand positions
|
|
537
|
+
to contract at that step; the result is appended to the end.
|
|
538
|
+
- ``False``: treated as ``'auto'``.
|
|
539
|
+
symmetry : SymmetryGroup or symmetry shorthand, optional
|
|
540
|
+
Declares output symmetry and wraps the validated result as a
|
|
541
|
+
``SymmetricTensor``. This does NOT declare input symmetry; use
|
|
542
|
+
``flops.as_symmetric()`` for that.
|
|
543
|
+
|
|
544
|
+
Returns
|
|
545
|
+
-------
|
|
546
|
+
numpy.ndarray or SymmetricTensor
|
|
547
|
+
The result of the einsum.
|
|
548
|
+
|
|
549
|
+
Raises
|
|
550
|
+
------
|
|
551
|
+
BudgetExhaustedError
|
|
552
|
+
If the operation would exceed the FLOP budget.
|
|
553
|
+
NoBudgetContextError
|
|
554
|
+
If called outside a ``BudgetContext``.
|
|
555
|
+
SymmetryError
|
|
556
|
+
If ``symmetry`` is provided but the result
|
|
557
|
+
does not satisfy the declared symmetry. Validation checks the
|
|
558
|
+
data against each generator of the group.
|
|
559
|
+
"""
|
|
560
|
+
budget = require_budget()
|
|
561
|
+
info = _resolve_cost_and_output_symmetry(subscripts, *operands, optimize=optimize)
|
|
562
|
+
canonical_subscripts = info.canonical_subscripts
|
|
563
|
+
accumulation_cost = info.accumulation
|
|
564
|
+
path_info = info.path_info
|
|
565
|
+
shapes = info.shapes
|
|
566
|
+
output_subscript = info.output_subscript
|
|
567
|
+
|
|
568
|
+
# User-declared symmetry overrides the helper's inferred symmetry;
|
|
569
|
+
# otherwise honor an existing SymmetricTensor `out=` operand.
|
|
570
|
+
if symmetry is not None:
|
|
571
|
+
target_symmetry = normalize_symmetry_input(symmetry, ndim=len(output_subscript))
|
|
572
|
+
else:
|
|
573
|
+
target_symmetry = info.output_symmetry
|
|
574
|
+
effective_out_symmetry = target_symmetry
|
|
575
|
+
if effective_out_symmetry is None and isinstance(out, SymmetricTensor):
|
|
576
|
+
effective_out_symmetry = out.symmetry
|
|
577
|
+
target_symmetry = _prepare_symmetric_out(out, effective_out_symmetry)
|
|
578
|
+
|
|
579
|
+
with budget.deduct(
|
|
580
|
+
"einsum",
|
|
581
|
+
flop_cost=accumulation_cost.total,
|
|
582
|
+
subscripts=canonical_subscripts,
|
|
583
|
+
shapes=tuple(shapes),
|
|
584
|
+
):
|
|
585
|
+
if path_info.steps:
|
|
586
|
+
result = _execute_pairwise(path_info, list(operands))
|
|
587
|
+
else:
|
|
588
|
+
result = _call_numpy(
|
|
589
|
+
_np.einsum,
|
|
590
|
+
canonical_subscripts,
|
|
591
|
+
*[_to_base_ndarray(o) for o in operands],
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
if out is not None:
|
|
595
|
+
_validate_result_symmetry(result, target_symmetry)
|
|
596
|
+
_np.copyto(_np.asarray(out), _np.asarray(result), casting="unsafe")
|
|
597
|
+
maybe_check_nan_inf(out, "einsum")
|
|
598
|
+
return out
|
|
599
|
+
|
|
600
|
+
if target_symmetry is not None:
|
|
601
|
+
_validate_result_symmetry(result, target_symmetry)
|
|
602
|
+
result = SymmetricTensor(_np.asarray(result), symmetry=target_symmetry)
|
|
603
|
+
else:
|
|
604
|
+
result = _asflopscope(_np.asarray(result))
|
|
605
|
+
|
|
606
|
+
maybe_check_nan_inf(result, "einsum")
|
|
607
|
+
return result # type: ignore[return-value]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@_counted_wrapper
|
|
611
|
+
def einsum_path(
|
|
612
|
+
subscripts: str,
|
|
613
|
+
*operands: _np.ndarray,
|
|
614
|
+
optimize: str | bool | list[Any] = "auto",
|
|
615
|
+
) -> tuple[list[Any], Any]:
|
|
616
|
+
"""Compute the optimal contraction path without executing.
|
|
617
|
+
|
|
618
|
+
Returns ``(path, PathInfo)`` with zero budget cost. The returned
|
|
619
|
+
``path`` can be passed back to ``fnp.einsum(..., optimize=path)``
|
|
620
|
+
to execute with that exact contraction order.
|
|
621
|
+
|
|
622
|
+
Parameters
|
|
623
|
+
----------
|
|
624
|
+
subscripts : str
|
|
625
|
+
Einstein summation subscript string.
|
|
626
|
+
*operands : numpy.ndarray
|
|
627
|
+
Input arrays.
|
|
628
|
+
optimize : str, bool, or list of tuple, optional
|
|
629
|
+
Path optimization strategy. Default ``'auto'``.
|
|
630
|
+
|
|
631
|
+
Returns
|
|
632
|
+
-------
|
|
633
|
+
path : list of tuple of int
|
|
634
|
+
The contraction path. Pass to ``fnp.einsum(..., optimize=path)``.
|
|
635
|
+
info : PathInfo
|
|
636
|
+
Diagnostics including per-step costs and symmetry savings.
|
|
637
|
+
"""
|
|
638
|
+
budget = require_budget()
|
|
639
|
+
with budget.deduct("einsum_path", flop_cost=1, subscripts=None, shapes=()):
|
|
640
|
+
pass
|
|
641
|
+
canonical_subscripts, input_parts, output_subscript, shapes, path_info = (
|
|
642
|
+
_get_path_info(
|
|
643
|
+
subscripts,
|
|
644
|
+
operands,
|
|
645
|
+
optimize,
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
accumulation_cost = _get_accumulation_cost(
|
|
650
|
+
canonical_subscripts=canonical_subscripts,
|
|
651
|
+
input_parts=tuple(input_parts),
|
|
652
|
+
output_subscript=output_subscript,
|
|
653
|
+
shapes=shapes,
|
|
654
|
+
operands=tuple(operands),
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
from flopscope._accumulation._path_info import FlopscopePathInfo
|
|
658
|
+
|
|
659
|
+
path_info = FlopscopePathInfo.from_inner(
|
|
660
|
+
inner=path_info,
|
|
661
|
+
accumulation=accumulation_cost,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
return list(path_info.path), path_info
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# ── Accumulation cost helper + cache ─────────────────────────────────
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
from flopscope._accumulation._cache import ( # noqa: E402, F401
|
|
671
|
+
_accumulation_cache,
|
|
672
|
+
get_accumulation_cost_cached,
|
|
673
|
+
)
|
|
674
|
+
from flopscope._accumulation._cache import ( # noqa: E402
|
|
675
|
+
rebuild_accumulation_cache as _rebuild_accumulation_cache_fn,
|
|
676
|
+
)
|
|
677
|
+
from flopscope._accumulation._public import ( # noqa: E402
|
|
678
|
+
_accumulation_fingerprint,
|
|
679
|
+
_identity_pattern,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _get_accumulation_cost(
|
|
684
|
+
*,
|
|
685
|
+
canonical_subscripts: str,
|
|
686
|
+
input_parts: tuple,
|
|
687
|
+
output_subscript: str,
|
|
688
|
+
shapes: tuple,
|
|
689
|
+
operands: tuple,
|
|
690
|
+
):
|
|
691
|
+
"""Cached accumulation-cost lookup for einsum() / einsum_path()."""
|
|
692
|
+
# Resolve partition_budget to the active setting BEFORE cache lookup so
|
|
693
|
+
# the cache key reflects the budget used; otherwise stale entries from a
|
|
694
|
+
# prior setting value can leak across calls.
|
|
695
|
+
partition_budget = cast(int, get_setting("partition_budget"))
|
|
696
|
+
return get_accumulation_cost_cached(
|
|
697
|
+
canonical_subscripts=canonical_subscripts,
|
|
698
|
+
input_parts=tuple(input_parts),
|
|
699
|
+
output_subscript=output_subscript,
|
|
700
|
+
shapes=shapes,
|
|
701
|
+
sym_fingerprint=_accumulation_fingerprint(operands),
|
|
702
|
+
identity_pattern=_identity_pattern(operands),
|
|
703
|
+
partition_budget=partition_budget,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _rebuild_accumulation_cache():
|
|
708
|
+
"""Rebuild the accumulation cache with the current configured maxsize."""
|
|
709
|
+
_rebuild_accumulation_cache_fn(cast(int, get_setting("einsum_path_cache_size")))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
import sys as _sys # noqa: E402
|
|
713
|
+
|
|
714
|
+
from flopscope._ndarray import _asflopscope # noqa: E402
|
|
715
|
+
from flopscope._ndarray import wrap_module_returns as _wrap_module_returns # noqa: E402
|
|
716
|
+
|
|
717
|
+
_wrap_module_returns(_sys.modules[__name__], skip_names={"einsum", "einsum_path"})
|