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.
Files changed (115) hide show
  1. benchmarks/__init__.py +1 -0
  2. benchmarks/__main__.py +6 -0
  3. benchmarks/_baseline.py +171 -0
  4. benchmarks/_bitwise.py +231 -0
  5. benchmarks/_complex.py +176 -0
  6. benchmarks/_contractions.py +291 -0
  7. benchmarks/_fft.py +198 -0
  8. benchmarks/_impl_urls.py +139 -0
  9. benchmarks/_linalg.py +197 -0
  10. benchmarks/_linalg_delegates.py +407 -0
  11. benchmarks/_metadata.py +141 -0
  12. benchmarks/_misc.py +653 -0
  13. benchmarks/_perf.py +321 -0
  14. benchmarks/_perm_group_calibration.py +175 -0
  15. benchmarks/_pointwise.py +372 -0
  16. benchmarks/_polynomial.py +193 -0
  17. benchmarks/_random.py +209 -0
  18. benchmarks/_reductions.py +136 -0
  19. benchmarks/_sorting.py +289 -0
  20. benchmarks/_stats.py +137 -0
  21. benchmarks/_window.py +92 -0
  22. benchmarks/accumulation/__init__.py +0 -0
  23. benchmarks/accumulation/bench_cost_compute.py +138 -0
  24. benchmarks/dashboard.py +312 -0
  25. benchmarks/runner.py +636 -0
  26. flopscope/__init__.py +273 -0
  27. flopscope/_accumulation/__init__.py +13 -0
  28. flopscope/_accumulation/_bipartite.py +121 -0
  29. flopscope/_accumulation/_burnside.py +51 -0
  30. flopscope/_accumulation/_cache.py +146 -0
  31. flopscope/_accumulation/_components.py +153 -0
  32. flopscope/_accumulation/_cost.py +1414 -0
  33. flopscope/_accumulation/_cost_descriptions.py +63 -0
  34. flopscope/_accumulation/_detection.py +318 -0
  35. flopscope/_accumulation/_ladder.py +191 -0
  36. flopscope/_accumulation/_output_orbit.py +104 -0
  37. flopscope/_accumulation/_partition.py +290 -0
  38. flopscope/_accumulation/_path_info.py +211 -0
  39. flopscope/_accumulation/_public.py +169 -0
  40. flopscope/_accumulation/_reduction.py +310 -0
  41. flopscope/_accumulation/_regimes.py +303 -0
  42. flopscope/_accumulation/_shape.py +33 -0
  43. flopscope/_accumulation/_wreath.py +209 -0
  44. flopscope/_budget.py +1027 -0
  45. flopscope/_config.py +118 -0
  46. flopscope/_counting_ops.py +451 -0
  47. flopscope/_display.py +478 -0
  48. flopscope/_docstrings.py +59 -0
  49. flopscope/_dtypes.py +20 -0
  50. flopscope/_einsum.py +717 -0
  51. flopscope/_errstate.py +25 -0
  52. flopscope/_flops.py +282 -0
  53. flopscope/_free_ops.py +2654 -0
  54. flopscope/_ndarray.py +1126 -0
  55. flopscope/_opt_einsum/LICENSE +21 -0
  56. flopscope/_opt_einsum/NOTICE +59 -0
  57. flopscope/_opt_einsum/__init__.py +209 -0
  58. flopscope/_opt_einsum/_contract.py +1478 -0
  59. flopscope/_opt_einsum/_helpers.py +164 -0
  60. flopscope/_opt_einsum/_hsluv.py +273 -0
  61. flopscope/_opt_einsum/_path_random.py +462 -0
  62. flopscope/_opt_einsum/_paths.py +1653 -0
  63. flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
  64. flopscope/_opt_einsum/_symmetry.py +140 -0
  65. flopscope/_opt_einsum/_typing.py +37 -0
  66. flopscope/_perm_group.py +717 -0
  67. flopscope/_pointwise.py +2522 -0
  68. flopscope/_polynomial.py +278 -0
  69. flopscope/_registry.py +3216 -0
  70. flopscope/_sorting_ops.py +571 -0
  71. flopscope/_symmetric.py +812 -0
  72. flopscope/_symmetry_transport.py +510 -0
  73. flopscope/_symmetry_utils.py +669 -0
  74. flopscope/_type_info.py +12 -0
  75. flopscope/_unwrap.py +70 -0
  76. flopscope/_validation.py +83 -0
  77. flopscope/_version_check.py +46 -0
  78. flopscope/_weights.py +195 -0
  79. flopscope/_window.py +177 -0
  80. flopscope/accounting.py +565 -0
  81. flopscope/data/default_weights.json +462 -0
  82. flopscope/data/weights.csv +509 -0
  83. flopscope/errors.py +197 -0
  84. flopscope/numpy/__init__.py +878 -0
  85. flopscope/numpy/fft/__init__.py +55 -0
  86. flopscope/numpy/fft/_free.py +51 -0
  87. flopscope/numpy/fft/_transforms.py +695 -0
  88. flopscope/numpy/linalg/__init__.py +105 -0
  89. flopscope/numpy/linalg/_aliases.py +126 -0
  90. flopscope/numpy/linalg/_compound.py +161 -0
  91. flopscope/numpy/linalg/_decompositions.py +353 -0
  92. flopscope/numpy/linalg/_properties.py +533 -0
  93. flopscope/numpy/linalg/_solvers.py +444 -0
  94. flopscope/numpy/linalg/_svd.py +122 -0
  95. flopscope/numpy/random/__init__.py +684 -0
  96. flopscope/numpy/random/_cost_formulas.py +115 -0
  97. flopscope/numpy/random/_counted_classes.py +241 -0
  98. flopscope/numpy/testing/__init__.py +13 -0
  99. flopscope/numpy/typing/__init__.py +30 -0
  100. flopscope/py.typed +0 -0
  101. flopscope/stats/__init__.py +84 -0
  102. flopscope/stats/_base.py +77 -0
  103. flopscope/stats/_cauchy.py +146 -0
  104. flopscope/stats/_erf.py +190 -0
  105. flopscope/stats/_expon.py +146 -0
  106. flopscope/stats/_laplace.py +150 -0
  107. flopscope/stats/_logistic.py +148 -0
  108. flopscope/stats/_lognorm.py +160 -0
  109. flopscope/stats/_ndtri.py +133 -0
  110. flopscope/stats/_norm.py +149 -0
  111. flopscope/stats/_truncnorm.py +186 -0
  112. flopscope/stats/_uniform.py +141 -0
  113. flopscope-0.2.0.dist-info/METADATA +23 -0
  114. flopscope-0.2.0.dist-info/RECORD +115 -0
  115. 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"})