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
@@ -0,0 +1,1653 @@
1
+ """Contains the path technology behind opt_einsum in addition to several path helpers."""
2
+
3
+ import bisect
4
+ import functools
5
+ import heapq
6
+ import itertools
7
+ import operator
8
+ import random
9
+ import re
10
+ from collections import Counter, defaultdict
11
+ from collections import Counter as CounterType
12
+ from collections.abc import Callable, Generator, Sequence
13
+ from typing import Any
14
+
15
+ from ._helpers import compute_size_by_dict, flop_count
16
+ from ._subgraph_symmetry import SubgraphSymmetryOracle
17
+ from ._symmetry import (
18
+ SymmetryGroup,
19
+ symmetric_flop_count,
20
+ unique_elements,
21
+ )
22
+ from ._typing import ArrayIndexType, PathSearchFunctionType, PathType, TensorShapeType
23
+
24
+ __all__ = [
25
+ "optimal",
26
+ "BranchBound",
27
+ "branch",
28
+ "greedy",
29
+ "auto",
30
+ "auto_hq",
31
+ "get_path_fn",
32
+ "DynamicProgramming",
33
+ "dynamic_programming",
34
+ ]
35
+
36
+ _UNLIMITED_MEM = {-1, None, float("inf")}
37
+
38
+
39
+ class PathOptimizer:
40
+ r"""Base class for different path optimizers to inherit from.
41
+
42
+ Subclassed optimizers should define a call method with signature:
43
+
44
+ ```python
45
+ def __call__(self, inputs: List[ArrayIndexType], output: ArrayIndexType, size_dict: dict[str, int], memory_limit: int | None = None) -> list[tuple[int, ...]]:
46
+ \"\"\"
47
+ Parameters:
48
+ inputs: The indices of each input array.
49
+ outputs: The output indices
50
+ size_dict: The size of each index
51
+ memory_limit: If given, the maximum allowed memory.
52
+ \"\"\"
53
+ # ... compute path here ...
54
+ return path
55
+ ```
56
+
57
+ where `path` is a list of int-tuples specifying a contraction order.
58
+ """
59
+
60
+ def _check_args_against_first_call(
61
+ self,
62
+ inputs: list[ArrayIndexType],
63
+ output: ArrayIndexType,
64
+ size_dict: dict[str, int],
65
+ ) -> None:
66
+ """Utility that stateful optimizers can use to ensure they are not
67
+ called with different contractions across separate runs.
68
+ """
69
+ args = (inputs, output, size_dict)
70
+ if not hasattr(self, "_first_call_args"):
71
+ # simply set the attribute as currently there is no global PathOptimizer init
72
+ self._first_call_args = args
73
+ elif args != self._first_call_args:
74
+ raise ValueError(
75
+ "The arguments specifying the contraction that this path optimizer "
76
+ "instance was called with have changed - try creating a new instance."
77
+ )
78
+
79
+ def __call__(
80
+ self,
81
+ inputs: list[ArrayIndexType],
82
+ output: ArrayIndexType,
83
+ size_dict: dict[str, int],
84
+ memory_limit: int | None = None,
85
+ **kwargs: Any,
86
+ ) -> PathType:
87
+ raise NotImplementedError
88
+
89
+
90
+ def ssa_to_linear(ssa_path: PathType) -> PathType:
91
+ """Convert a path with static single assignment ids to a path with recycled
92
+ linear ids.
93
+
94
+ Example:
95
+ ```python
96
+ ssa_to_linear([(0, 3), (2, 4), (1, 5)])
97
+ #> [(0, 3), (1, 2), (0, 1)]
98
+ ```
99
+ """
100
+ n = sum(map(len, ssa_path)) - len(ssa_path) + 1
101
+ ids = list(range(n))
102
+ path = []
103
+ ssa = n
104
+ for scon in ssa_path:
105
+ con = sorted([bisect.bisect_left(ids, s) for s in scon])
106
+ for j in reversed(con):
107
+ ids.pop(j)
108
+ ids.append(ssa)
109
+ path.append(con)
110
+ ssa += 1
111
+ return [tuple(x) for x in path]
112
+
113
+
114
+ def linear_to_ssa(path: PathType) -> PathType:
115
+ """Convert a path with recycled linear ids to a path with static single
116
+ assignment ids.
117
+
118
+ Exmaple:
119
+ ```python
120
+ linear_to_ssa([(0, 3), (1, 2), (0, 1)])
121
+ #> [(0, 3), (2, 4), (1, 5)]
122
+ ```
123
+ """
124
+ num_inputs = sum(map(len, path)) - len(path) + 1
125
+ linear_to_ssa = list(range(num_inputs))
126
+ new_ids = itertools.count(num_inputs)
127
+ ssa_path = []
128
+ for ids in path:
129
+ ssa_path.append(tuple(linear_to_ssa[id_] for id_ in ids))
130
+ for id_ in sorted(ids, reverse=True):
131
+ del linear_to_ssa[id_]
132
+ linear_to_ssa.append(next(new_ids))
133
+ return ssa_path
134
+
135
+
136
+ def calc_k12_flops(
137
+ inputs: tuple[frozenset[str]],
138
+ output: frozenset[str],
139
+ remaining: frozenset[int],
140
+ i: int,
141
+ j: int,
142
+ size_dict: dict[str, int],
143
+ oracle: SubgraphSymmetryOracle | None = None,
144
+ ssa_to_subset: dict[int, frozenset[int]] | None = None,
145
+ ) -> tuple[frozenset[str], int, SymmetryGroup | None]:
146
+ """Calculate the resulting indices and flops for a potential pairwise
147
+ contraction.
148
+
149
+ Parameters
150
+ ----------
151
+ oracle : SubgraphSymmetryOracle | None
152
+ Subset-keyed symmetry oracle. When provided together with
153
+ ``ssa_to_subset``, the symmetry of the output tensor is looked
154
+ up via ``oracle.sym(ssa_to_subset[i] | ssa_to_subset[j])`` and
155
+ used to reduce the FLOP count.
156
+ ssa_to_subset : dict[int, frozenset[int]] | None
157
+ Mapping from SSA tensor id to the subset of original operand
158
+ positions that tensor represents.
159
+
160
+ Returns
161
+ -------
162
+ k12 : frozenset[str]
163
+ The resulting indices of the potential tensor.
164
+ cost : int
165
+ Estimated flop count of the operation.
166
+ sym12 : SymmetryGroup | None
167
+ Symmetry of the result tensor (None when no oracle or no symmetry).
168
+ """
169
+ k1, k2 = inputs[i], inputs[j]
170
+ either = k1 | k2
171
+ keep = frozenset.union(output, *map(inputs.__getitem__, remaining - {i, j}))
172
+ k12 = either & keep
173
+ inner = bool(either - k12)
174
+
175
+ sym12: SymmetryGroup | None = None
176
+ if oracle is not None and ssa_to_subset is not None:
177
+ merged_subset = ssa_to_subset[i] | ssa_to_subset[j]
178
+ subset_sym = oracle.sym(merged_subset)
179
+ sym12 = subset_sym.output
180
+
181
+ from flopscope._config import get_setting
182
+
183
+ idx_removed = either - k12
184
+ cost = symmetric_flop_count(
185
+ either,
186
+ inner,
187
+ 2,
188
+ size_dict,
189
+ output_group=subset_sym.output,
190
+ output_indices=k12,
191
+ inner_group=subset_sym.inner,
192
+ inner_indices=idx_removed if idx_removed else None,
193
+ use_inner_symmetry=bool(get_setting("use_inner_symmetry")),
194
+ )
195
+ else:
196
+ cost = flop_count(either, inner, 2, size_dict)
197
+
198
+ return k12, cost, sym12
199
+
200
+
201
+ def _compute_oversize_flops(
202
+ inputs: tuple[frozenset[str]],
203
+ remaining: list[ArrayIndexType],
204
+ output: ArrayIndexType,
205
+ size_dict: dict[str, int],
206
+ ) -> int:
207
+ """Compute the flop count for a contraction of all remaining arguments. This
208
+ is used when a memory limit means that no pairwise contractions can be made.
209
+ """
210
+ idx_contraction = frozenset.union(*map(inputs.__getitem__, remaining)) # type: ignore
211
+ inner = idx_contraction - output
212
+ num_terms = len(remaining)
213
+ return flop_count(idx_contraction, bool(inner), num_terms, size_dict)
214
+
215
+
216
+ def optimal(
217
+ inputs: list[ArrayIndexType],
218
+ output: ArrayIndexType,
219
+ size_dict: dict[str, int],
220
+ memory_limit: int | None = None,
221
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
222
+ ) -> PathType:
223
+ """Computes all possible pair contractions in a depth-first recursive manner."""
224
+ inputs_set = tuple(map(frozenset, inputs))
225
+ output_set = frozenset(output)
226
+ num_operands = len(inputs)
227
+
228
+ best_flops = {"flops": float("inf")}
229
+ best_ssa_path = {"ssa_path": (tuple(range(len(inputs))),)}
230
+ size_cache: dict[frozenset[str], int] = {}
231
+
232
+ # Initial SSA -> subset mapping: each original operand covers itself.
233
+ initial_ssa_to_subset: dict[int, frozenset[int]] = {
234
+ k: frozenset({k}) for k in range(num_operands)
235
+ }
236
+
237
+ # Result cache is valid because calc_k12_flops is now pure in
238
+ # (merged_subset,) — the oracle guarantees that two subsets with the
239
+ # same frozenset key produce the same symmetry.
240
+ result_cache: dict[
241
+ tuple[ArrayIndexType, ArrayIndexType, frozenset[int]],
242
+ tuple[frozenset[str], int, SymmetryGroup | None],
243
+ ] = {}
244
+
245
+ def _optimal_iterate(path, remaining, inputs, flops, ssa_to_subset):
246
+ if len(remaining) == 1:
247
+ best_flops["flops"] = flops
248
+ best_ssa_path["ssa_path"] = path
249
+ return
250
+
251
+ for i, j in itertools.combinations(remaining, 2):
252
+ if i > j:
253
+ i, j = j, i
254
+
255
+ merged_subset = ssa_to_subset[i] | ssa_to_subset[j]
256
+ cache_key = (inputs[i], inputs[j], merged_subset)
257
+ try:
258
+ k12, flops12, sym12 = result_cache[cache_key]
259
+ except KeyError:
260
+ k12, flops12, sym12 = result_cache[cache_key] = calc_k12_flops(
261
+ inputs,
262
+ output_set,
263
+ remaining,
264
+ i,
265
+ j,
266
+ size_dict,
267
+ oracle=symmetry_oracle,
268
+ ssa_to_subset=ssa_to_subset,
269
+ )
270
+
271
+ new_flops = flops + flops12
272
+ if new_flops >= best_flops["flops"]:
273
+ continue
274
+
275
+ if memory_limit not in _UNLIMITED_MEM:
276
+ try:
277
+ size12 = size_cache[k12]
278
+ except KeyError:
279
+ size12 = size_cache[k12] = compute_size_by_dict(k12, size_dict)
280
+ if size12 > memory_limit: # type: ignore[operator]
281
+ new_flops = flops + _compute_oversize_flops(
282
+ inputs, remaining, output_set, size_dict
283
+ )
284
+ if new_flops < best_flops["flops"]:
285
+ best_flops["flops"] = new_flops
286
+ best_ssa_path["ssa_path"] = path + (tuple(remaining),)
287
+ continue
288
+
289
+ new_ssa_to_subset = dict(ssa_to_subset)
290
+ new_ssa_to_subset[len(inputs)] = merged_subset
291
+
292
+ _optimal_iterate(
293
+ path=path + ((i, j),),
294
+ inputs=inputs + (k12,),
295
+ remaining=remaining - {i, j} | {len(inputs)},
296
+ flops=new_flops,
297
+ ssa_to_subset=new_ssa_to_subset,
298
+ )
299
+
300
+ _optimal_iterate(
301
+ path=(),
302
+ inputs=inputs_set,
303
+ remaining=set(range(len(inputs))),
304
+ flops=0,
305
+ ssa_to_subset=initial_ssa_to_subset,
306
+ )
307
+
308
+ return ssa_to_linear(best_ssa_path["ssa_path"])
309
+
310
+
311
+ # functions for comparing which of two paths is 'better'
312
+
313
+
314
+ def better_flops_first(flops: int, size: int, best_flops: int, best_size: int) -> bool:
315
+ return (flops, size) < (best_flops, best_size)
316
+
317
+
318
+ def better_size_first(flops: int, size: int, best_flops: int, best_size: int) -> bool:
319
+ return (size, flops) < (best_size, best_flops)
320
+
321
+
322
+ _BETTER_FNS = {
323
+ "flops": better_flops_first,
324
+ "size": better_size_first,
325
+ }
326
+
327
+
328
+ def get_better_fn(key: str) -> Callable[[int, int, int, int], bool]:
329
+ return _BETTER_FNS[key]
330
+
331
+
332
+ # functions for assigning a heuristic 'cost' to a potential contraction
333
+
334
+
335
+ def cost_memory_removed(
336
+ size12: int, size1: int, size2: int, k12: int, k1: int, k2: int
337
+ ) -> float:
338
+ """The default heuristic cost, corresponding to the total reduction in
339
+ memory of performing a contraction.
340
+ """
341
+ return size12 - size1 - size2
342
+
343
+
344
+ def cost_memory_removed_jitter(
345
+ size12: int, size1: int, size2: int, k12: int, k1: int, k2: int
346
+ ) -> float:
347
+ """Like memory-removed, but with a slight amount of noise that breaks ties
348
+ and thus jumbles the contractions a bit.
349
+ """
350
+ return random.gauss(1.0, 0.01) * (size12 - size1 - size2)
351
+
352
+
353
+ _COST_FNS = {
354
+ "memory-removed": cost_memory_removed,
355
+ "memory-removed-jitter": cost_memory_removed_jitter,
356
+ }
357
+
358
+
359
+ class BranchBound(PathOptimizer):
360
+ def __init__(
361
+ self,
362
+ nbranch: int | None = None,
363
+ cutoff_flops_factor: int = 4,
364
+ minimize: str = "flops",
365
+ cost_fn: str = "memory-removed",
366
+ ):
367
+ """Explores possible pair contractions in a depth-first recursive manner like
368
+ the `optimal` approach, but with extra heuristic early pruning of branches
369
+ as well sieving by `memory_limit` and the best path found so far.
370
+
371
+
372
+ Parameters:
373
+ nbranch: How many branches to explore at each contraction step. If None, explore
374
+ all possible branches. If an integer, branch into this many paths at
375
+ each step. Defaults to None.
376
+ cutoff_flops_factor: If at any point, a path is doing this much worse than the best path
377
+ found so far was, terminate it. The larger this is made, the more paths
378
+ will be fully explored and the slower the algorithm. Defaults to 4.
379
+ minimize: Whether to optimize the path with regard primarily to the total
380
+ estimated flop-count, or the size of the largest intermediate. The
381
+ option not chosen will still be used as a secondary criterion.
382
+ cost_fn: A function that returns a heuristic 'cost' of a potential contraction
383
+ with which to sort candidates. Should have signature
384
+ `cost_fn(size12, size1, size2, k12, k1, k2)`.
385
+ """
386
+ if (nbranch is not None) and nbranch < 1:
387
+ raise ValueError(
388
+ f"The number of branches must be at least one, `nbranch={nbranch}`."
389
+ )
390
+
391
+ self.nbranch = nbranch
392
+ self.cutoff_flops_factor = cutoff_flops_factor
393
+ self.minimize = minimize
394
+ self.cost_fn: Any = _COST_FNS.get(cost_fn, cost_fn)
395
+
396
+ self.better = get_better_fn(minimize)
397
+ self.best: dict[str, Any] = {"flops": float("inf"), "size": float("inf")}
398
+ self.best_progress: dict[int, float] = defaultdict(lambda: float("inf"))
399
+
400
+ @property
401
+ def path(self) -> PathType:
402
+ return ssa_to_linear(self.best["ssa_path"])
403
+
404
+ def __call__( # type: ignore[override]
405
+ self,
406
+ inputs_: list[ArrayIndexType],
407
+ output_: ArrayIndexType,
408
+ size_dict: dict[str, int],
409
+ memory_limit: int | None = None,
410
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
411
+ ) -> PathType:
412
+ """Parameters:
413
+ inputs_: List of sets that represent the lhs side of the einsum subscript
414
+ output_: Set that represents the rhs side of the overall einsum subscript
415
+ size_dict: Dictionary of index sizes
416
+ memory_limit: The maximum number of elements in a temporary array.
417
+ symmetry_oracle: Optional subgraph symmetry oracle.
418
+
419
+ Returns:
420
+ path: The contraction order within the memory limit constraint.
421
+
422
+ Examples:
423
+ ```python
424
+ isets = [set('abd'), set('ac'), set('bdc')]
425
+ oset = set('')
426
+ idx_sizes = {'a': 1, 'b':2, 'c':3, 'd':4}
427
+ optimal(isets, oset, idx_sizes, 5000)
428
+ #> [(0, 2), (0, 1)]
429
+ """
430
+ self._check_args_against_first_call(inputs_, output_, size_dict)
431
+
432
+ inputs: tuple[frozenset[str]] = tuple(map(frozenset, inputs_)) # type: ignore[assignment]
433
+ output: frozenset[str] = frozenset(output_)
434
+ num_operands = len(inputs_)
435
+
436
+ size_cache = {k: compute_size_by_dict(k, size_dict) for k in inputs}
437
+
438
+ initial_ssa_to_subset: dict[int, frozenset[int]] = {
439
+ k: frozenset({k}) for k in range(num_operands)
440
+ }
441
+
442
+ # Result cache is valid with the oracle — key by (k1, k2, merged_subset)
443
+ result_cache: dict[
444
+ tuple[frozenset[str], frozenset[str], frozenset[int]],
445
+ tuple[frozenset[str], int, SymmetryGroup | None],
446
+ ] = {}
447
+
448
+ def _branch_iterate(path, inputs, remaining, flops, size, ssa_to_subset):
449
+ # reached end of path (only ever get here if flops is best found so far)
450
+ if len(remaining) == 1:
451
+ self.best["size"] = size
452
+ self.best["flops"] = flops
453
+ self.best["ssa_path"] = path
454
+ return
455
+
456
+ def _assess_candidate(
457
+ k1: frozenset[str], k2: frozenset[str], i: int, j: int
458
+ ) -> Any:
459
+ # find resulting indices and flops
460
+ merged_subset = ssa_to_subset[i] | ssa_to_subset[j]
461
+ cache_key = (k1, k2, merged_subset)
462
+ try:
463
+ k12, flops12, sym12 = result_cache[cache_key]
464
+ except KeyError:
465
+ k12, flops12, sym12 = result_cache[cache_key] = calc_k12_flops(
466
+ inputs,
467
+ output,
468
+ remaining,
469
+ i,
470
+ j,
471
+ size_dict,
472
+ oracle=symmetry_oracle,
473
+ ssa_to_subset=ssa_to_subset,
474
+ )
475
+
476
+ try:
477
+ size12 = size_cache[k12]
478
+ except KeyError:
479
+ size12 = size_cache[k12] = compute_size_by_dict(k12, size_dict)
480
+
481
+ new_flops = flops + flops12
482
+ new_size = max(size, size12)
483
+
484
+ # sieve based on current best i.e. check flops and size still better
485
+ if not self.better(
486
+ new_flops, new_size, self.best["flops"], self.best["size"]
487
+ ):
488
+ return None
489
+
490
+ # compare to how the best method was doing as this point
491
+ if new_flops < self.best_progress[len(inputs)]:
492
+ self.best_progress[len(inputs)] = new_flops
493
+ # sieve based on current progress relative to best
494
+ elif (
495
+ new_flops
496
+ > self.cutoff_flops_factor * self.best_progress[len(inputs)]
497
+ ):
498
+ return None
499
+
500
+ # sieve based on memory limit
501
+ if (memory_limit not in _UNLIMITED_MEM) and (size12 > memory_limit): # type: ignore
502
+ # terminate path here, but check all-terms contract first
503
+ new_flops = flops + _compute_oversize_flops(
504
+ inputs, remaining, output_, size_dict
505
+ )
506
+ if new_flops < self.best["flops"]:
507
+ self.best["flops"] = new_flops
508
+ self.best["ssa_path"] = path + (tuple(remaining),)
509
+ return None
510
+
511
+ # set cost heuristic in order to locally sort possible contractions
512
+ size1, size2 = size_cache[inputs[i]], size_cache[inputs[j]]
513
+ cost = self.cost_fn(size12, size1, size2, k12, k1, k2)
514
+
515
+ return cost, flops12, new_flops, new_size, (i, j), k12, sym12
516
+
517
+ # check all possible remaining paths
518
+ candidates = []
519
+ for i, j in itertools.combinations(remaining, 2):
520
+ if i > j:
521
+ i, j = j, i
522
+ k1, k2 = inputs[i], inputs[j]
523
+
524
+ # initially ignore outer products
525
+ if k1.isdisjoint(k2):
526
+ continue
527
+
528
+ candidate = _assess_candidate(k1, k2, i, j)
529
+ if candidate:
530
+ heapq.heappush(candidates, candidate)
531
+
532
+ # assess outer products if nothing left
533
+ if not candidates:
534
+ for i, j in itertools.combinations(remaining, 2):
535
+ if i > j:
536
+ i, j = j, i
537
+ k1, k2 = inputs[i], inputs[j]
538
+ candidate = _assess_candidate(k1, k2, i, j)
539
+ if candidate:
540
+ heapq.heappush(candidates, candidate)
541
+
542
+ # recurse into all or some of the best candidate contractions
543
+ bi = 0
544
+ while (self.nbranch is None or bi < self.nbranch) and candidates:
545
+ _, _, new_flops, new_size, (i, j), k12, sym12 = heapq.heappop(
546
+ candidates
547
+ )
548
+
549
+ new_ssa_to_subset = dict(ssa_to_subset)
550
+ new_ssa_to_subset[len(inputs)] = ssa_to_subset[i] | ssa_to_subset[j]
551
+
552
+ _branch_iterate(
553
+ path=path + ((i, j),),
554
+ inputs=inputs + (k12,),
555
+ remaining=(remaining - {i, j}) | {len(inputs)},
556
+ flops=new_flops,
557
+ size=new_size,
558
+ ssa_to_subset=new_ssa_to_subset,
559
+ )
560
+ bi += 1
561
+
562
+ _branch_iterate(
563
+ path=(),
564
+ inputs=inputs,
565
+ remaining=set(range(len(inputs))),
566
+ flops=0,
567
+ size=0,
568
+ ssa_to_subset=initial_ssa_to_subset,
569
+ )
570
+
571
+ return self.path
572
+
573
+
574
+ def branch(
575
+ inputs: list[ArrayIndexType],
576
+ output: ArrayIndexType,
577
+ size_dict: dict[str, int],
578
+ memory_limit: int | None = None,
579
+ nbranch: int | None = None,
580
+ cutoff_flops_factor: int = 4,
581
+ minimize: str = "flops",
582
+ cost_fn: str = "memory-removed",
583
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
584
+ ) -> PathType:
585
+ optimizer = BranchBound(
586
+ nbranch=nbranch,
587
+ cutoff_flops_factor=cutoff_flops_factor,
588
+ minimize=minimize,
589
+ cost_fn=cost_fn,
590
+ )
591
+ return optimizer(
592
+ inputs,
593
+ output,
594
+ size_dict,
595
+ memory_limit,
596
+ symmetry_oracle=symmetry_oracle,
597
+ )
598
+
599
+
600
+ branch_all = functools.partial(branch, nbranch=None)
601
+ branch_2 = functools.partial(branch, nbranch=2)
602
+ branch_1 = functools.partial(branch, nbranch=1)
603
+
604
+ GreedyCostType = tuple[int, int, int]
605
+ GreedyContractionType = tuple[
606
+ GreedyCostType, ArrayIndexType, ArrayIndexType, ArrayIndexType
607
+ ] # Cost, t1,t2->t3
608
+
609
+
610
+ def _get_candidate(
611
+ output: ArrayIndexType,
612
+ sizes: dict[str, int],
613
+ remaining: dict[ArrayIndexType, int],
614
+ footprints: dict[ArrayIndexType, int],
615
+ dim_ref_counts: dict[int, set[str]],
616
+ k1: ArrayIndexType,
617
+ k2: ArrayIndexType,
618
+ cost_fn: Any,
619
+ ) -> GreedyContractionType:
620
+ either = k1 | k2
621
+ two = k1 & k2
622
+ one = either - two
623
+ k12 = (either & output) | (two & dim_ref_counts[3]) | (one & dim_ref_counts[2])
624
+ size12 = compute_size_by_dict(k12, sizes)
625
+ cost = cost_fn(
626
+ size12,
627
+ footprints[k1],
628
+ footprints[k2],
629
+ k12,
630
+ k1,
631
+ k2,
632
+ )
633
+ id1 = remaining[k1]
634
+ id2 = remaining[k2]
635
+ if id1 > id2:
636
+ k1, id1, k2, id2 = k2, id2, k1, id1
637
+ cost = cost, id2, id1 # break ties to ensure determinism
638
+ return cost, k1, k2, k12
639
+
640
+
641
+ def _push_candidate(
642
+ output: ArrayIndexType,
643
+ sizes: dict[str, Any],
644
+ remaining: dict[ArrayIndexType, int],
645
+ footprints: dict[ArrayIndexType, int],
646
+ dim_ref_counts: dict[int, set[str]],
647
+ k1: ArrayIndexType,
648
+ k2s: list[ArrayIndexType],
649
+ queue: list[GreedyContractionType],
650
+ push_all: bool,
651
+ cost_fn: Any,
652
+ ) -> None:
653
+ candidates = (
654
+ _get_candidate(
655
+ output,
656
+ sizes,
657
+ remaining,
658
+ footprints,
659
+ dim_ref_counts,
660
+ k1,
661
+ k2,
662
+ cost_fn,
663
+ )
664
+ for k2 in k2s
665
+ )
666
+ if push_all:
667
+ # want to do this if we e.g. are using a custom 'choose_fn'
668
+ for candidate in candidates:
669
+ heapq.heappush(queue, candidate)
670
+ else:
671
+ heapq.heappush(queue, min(candidates))
672
+
673
+
674
+ def _update_ref_counts(
675
+ dim_to_keys: dict[str, set[ArrayIndexType]],
676
+ dim_ref_counts: dict[int, set[str]],
677
+ dims: ArrayIndexType,
678
+ ) -> None:
679
+ for dim in dims:
680
+ count = len(dim_to_keys[dim])
681
+ if count <= 1:
682
+ dim_ref_counts[2].discard(dim)
683
+ dim_ref_counts[3].discard(dim)
684
+ elif count == 2:
685
+ dim_ref_counts[2].add(dim)
686
+ dim_ref_counts[3].discard(dim)
687
+ else:
688
+ dim_ref_counts[2].add(dim)
689
+ dim_ref_counts[3].add(dim)
690
+
691
+
692
+ def _simple_chooser(queue, remaining):
693
+ """Default contraction chooser that simply takes the minimum cost option."""
694
+ cost, k1, k2, k12 = heapq.heappop(queue)
695
+ if k1 not in remaining or k2 not in remaining:
696
+ return None # candidate is obsolete
697
+ return cost, k1, k2, k12
698
+
699
+
700
+ def ssa_greedy_optimize(
701
+ inputs: list[ArrayIndexType],
702
+ output: ArrayIndexType,
703
+ sizes: dict[str, int],
704
+ choose_fn: Any = None,
705
+ cost_fn: Any = "memory-removed",
706
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
707
+ ssa_to_subset: dict[int, frozenset[int]] | None = None,
708
+ ) -> PathType:
709
+ """This is the core function for :func:`greedy` but produces a path with
710
+ static single assignment ids rather than recycled linear ids.
711
+ SSA ids are cheaper to work with and easier to reason about.
712
+ """
713
+ if len(inputs) == 1:
714
+ # Perform a single contraction to match output shape.
715
+ return [(0,)]
716
+
717
+ # set the function that assigns a heuristic cost to a possible contraction
718
+ cost_fn = _COST_FNS.get(cost_fn, cost_fn)
719
+
720
+ # set the function that chooses which contraction to take
721
+ if choose_fn is None:
722
+ choose_fn = _simple_chooser
723
+ push_all = False
724
+ else:
725
+ # assume chooser wants access to all possible contractions
726
+ push_all = True
727
+
728
+ num_operands = len(inputs)
729
+ if ssa_to_subset is None:
730
+ ssa_to_subset = {k: frozenset({k}) for k in range(num_operands)}
731
+
732
+ # A dim that is common to all tensors might as well be an output dim, since it
733
+ # cannot be contracted until the final step. This avoids an expensive all-pairs
734
+ # comparison to search for possible contractions at each step, leading to speedup
735
+ # in many practical problems where all tensors share a common batch dimension.
736
+ fs_inputs = [frozenset(x) for x in inputs]
737
+ output = frozenset(output) | frozenset.intersection(*fs_inputs)
738
+
739
+ # Deduplicate shapes by eagerly computing Hadamard products.
740
+ remaining: dict[ArrayIndexType, int] = {} # key -> ssa_id
741
+ ssa_ids = itertools.count(len(fs_inputs))
742
+ ssa_path: list[TensorShapeType] = []
743
+ for ssa_id, key in enumerate(fs_inputs):
744
+ if key in remaining:
745
+ ssa_path.append((remaining[key], ssa_id))
746
+ new_id = next(ssa_ids)
747
+ # Hadamard dedup: merge subsets
748
+ old_id = remaining[key]
749
+ ssa_to_subset[new_id] = ssa_to_subset[old_id] | ssa_to_subset[ssa_id]
750
+ remaining[key] = new_id
751
+ else:
752
+ remaining[key] = ssa_id
753
+
754
+ # Keep track of possible contraction dims.
755
+ dim_to_keys = defaultdict(set)
756
+ for key in remaining:
757
+ for dim in key - output:
758
+ dim_to_keys[dim].add(key)
759
+
760
+ # Keep track of the number of tensors using each dim; when the dim is no longer
761
+ # used it can be contracted. Since we specialize to binary ops, we only care about
762
+ # ref counts of >=2 or >=3.
763
+ dim_ref_counts = {
764
+ count: {dim for dim, keys in dim_to_keys.items() if len(keys) >= count} - output
765
+ for count in [2, 3]
766
+ }
767
+
768
+ # Compute separable part of the objective function for contractions.
769
+ footprints = {key: compute_size_by_dict(key, sizes) for key in remaining}
770
+
771
+ # Find initial candidate contractions.
772
+ queue: list[GreedyContractionType] = []
773
+ for _dim, dim_keys in dim_to_keys.items():
774
+ dim_keys_list = sorted(dim_keys, key=remaining.__getitem__)
775
+ for i, k1 in enumerate(dim_keys_list[:-1]):
776
+ k2s_guess = dim_keys_list[1 + i :]
777
+ _push_candidate(
778
+ output,
779
+ sizes,
780
+ remaining,
781
+ footprints,
782
+ dim_ref_counts,
783
+ k1,
784
+ k2s_guess,
785
+ queue,
786
+ push_all,
787
+ cost_fn,
788
+ )
789
+
790
+ # Greedily contract pairs of tensors.
791
+ while queue:
792
+ con = choose_fn(queue, remaining)
793
+ if con is None:
794
+ continue # allow choose_fn to flag all candidates obsolete
795
+ cost, k1, k2, k12 = con
796
+
797
+ ssa_id1 = remaining.pop(k1)
798
+ ssa_id2 = remaining.pop(k2)
799
+ for dim in k1 - output:
800
+ dim_to_keys[dim].remove(k1)
801
+ for dim in k2 - output:
802
+ dim_to_keys[dim].remove(k2)
803
+ ssa_path.append((ssa_id1, ssa_id2))
804
+
805
+ if k12 in remaining:
806
+ hadamard_id = next(ssa_ids)
807
+ ssa_path.append((remaining[k12], hadamard_id))
808
+ old_id = remaining[k12]
809
+ merged = (
810
+ ssa_to_subset[old_id] | ssa_to_subset[ssa_id1] | ssa_to_subset[ssa_id2]
811
+ )
812
+ ssa_to_subset[hadamard_id] = merged
813
+ else:
814
+ for dim in k12 - output:
815
+ dim_to_keys[dim].add(k12)
816
+ new_ssa_id = next(ssa_ids)
817
+ remaining[k12] = new_ssa_id
818
+ ssa_to_subset[new_ssa_id] = ssa_to_subset[ssa_id1] | ssa_to_subset[ssa_id2]
819
+ _update_ref_counts(dim_to_keys, dim_ref_counts, k1 | k2 - output)
820
+
821
+ footprints[k12] = compute_size_by_dict(k12, sizes)
822
+
823
+ # Find new candidate contractions.
824
+ k1 = k12
825
+ k2s = {k2 for dim in k1 for k2 in dim_to_keys[dim]}
826
+ k2s.discard(k1)
827
+ if k2s:
828
+ _push_candidate(
829
+ output,
830
+ sizes,
831
+ remaining,
832
+ footprints,
833
+ dim_ref_counts,
834
+ k1,
835
+ list(k2s),
836
+ queue,
837
+ push_all,
838
+ cost_fn,
839
+ )
840
+
841
+ # Greedily compute pairwise outer products.
842
+ final_queue = [
843
+ (compute_size_by_dict(key & output, sizes), ssa_id, key)
844
+ for key, ssa_id in remaining.items()
845
+ ]
846
+ heapq.heapify(final_queue)
847
+ _, ssa_id1, k1 = heapq.heappop(final_queue)
848
+ while final_queue:
849
+ _, ssa_id2, k2 = heapq.heappop(final_queue)
850
+ ssa_path.append((min(ssa_id1, ssa_id2), max(ssa_id1, ssa_id2)))
851
+ k12 = (k1 | k2) & output
852
+ cost = compute_size_by_dict(k12, sizes)
853
+ ssa_id12 = next(ssa_ids)
854
+ ssa_to_subset[ssa_id12] = ssa_to_subset[ssa_id1] | ssa_to_subset[ssa_id2]
855
+ _, ssa_id1, k1 = heapq.heappushpop(final_queue, (cost, ssa_id12, k12))
856
+
857
+ return ssa_path
858
+
859
+
860
+ def greedy(
861
+ inputs: list[ArrayIndexType],
862
+ output: ArrayIndexType,
863
+ size_dict: dict[str, int],
864
+ memory_limit: int | None = None,
865
+ choose_fn: Any = None,
866
+ cost_fn: str = "memory-removed",
867
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
868
+ ) -> PathType:
869
+ """Finds the path by a three stage algorithm:
870
+
871
+ 1. Eagerly compute Hadamard products.
872
+ 2. Greedily compute contractions to maximize `removed_size`
873
+ 3. Greedily compute outer products.
874
+
875
+ This algorithm scales quadratically with respect to the
876
+ maximum number of elements sharing a common dim.
877
+
878
+ Parameters:
879
+ inputs: List of sets that represent the lhs side of the einsum subscript
880
+ output: Set that represents the rhs side of the overall einsum subscript
881
+ size_dict: Dictionary of index sizes
882
+ memory_limit: The maximum number of elements in a temporary array
883
+ choose_fn: A function that chooses which contraction to perform from the queue
884
+ cost_fn: A function that assigns a potential contraction a cost.
885
+ symmetry_oracle: Optional subgraph symmetry oracle.
886
+
887
+ Returns:
888
+ path: The contraction order (a list of tuples of ints).
889
+
890
+ Examples:
891
+ ```python
892
+ isets = [set('abd'), set('ac'), set('bdc')]
893
+ oset = set('')
894
+ idx_sizes = {'a': 1, 'b':2, 'c':3, 'd':4}
895
+ greedy(isets, oset, idx_sizes)
896
+ #> [(0, 2), (0, 1)]
897
+ ```
898
+ """
899
+ if memory_limit not in _UNLIMITED_MEM:
900
+ return branch(
901
+ inputs,
902
+ output,
903
+ size_dict,
904
+ memory_limit,
905
+ nbranch=1,
906
+ cost_fn=cost_fn,
907
+ symmetry_oracle=symmetry_oracle,
908
+ ) # type: ignore
909
+
910
+ ssa_path = ssa_greedy_optimize(
911
+ inputs,
912
+ output,
913
+ size_dict,
914
+ cost_fn=cost_fn,
915
+ choose_fn=choose_fn,
916
+ symmetry_oracle=symmetry_oracle,
917
+ )
918
+ return ssa_to_linear(ssa_path)
919
+
920
+
921
+ def _tree_to_sequence(tree: tuple[Any, ...]) -> PathType:
922
+ """Converts a contraction tree to a contraction path as it has to be
923
+ returned by path optimizers. A contraction tree can either be an int
924
+ (=no contraction) or a tuple containing the terms to be contracted. An
925
+ arbitrary number (>= 1) of terms can be contracted at once. Note that
926
+ contractions are commutative, e.g. (j, k, l) = (k, l, j). Note that in
927
+ general, solutions are not unique.
928
+
929
+ Parameters:
930
+ c: Contraction tree
931
+
932
+ Returns:
933
+ path: Contraction path
934
+
935
+ Examples:
936
+ ```python
937
+ _tree_to_sequence(((1,2),(0,(4,5,3))))
938
+ #> [(1, 2), (1, 2, 3), (0, 2), (0, 1)]
939
+ ```
940
+ """
941
+ if type(tree) == int: # noqa: E721
942
+ return []
943
+
944
+ c: list[tuple[Any, ...]] = [
945
+ tree
946
+ ] # list of remaining contractions (lower part of columns shown above)
947
+ t: list[int] = [] # list of elementary tensors (upper part of columns)
948
+ s: list[tuple[int, ...]] = [] # resulting contraction sequence
949
+
950
+ while len(c) > 0:
951
+ j = c.pop(-1)
952
+ s.insert(0, ())
953
+
954
+ for i in sorted([i for i in j if type(i) == int]): # noqa: E721
955
+ s[0] += (sum(1 for q in t if q < i),)
956
+ t.insert(s[0][-1], i)
957
+
958
+ for i_tup in [i_tup for i_tup in j if type(i_tup) != int]: # noqa: E721
959
+ s[0] += (len(t) + len(c),)
960
+ c.append(i_tup)
961
+
962
+ return s
963
+
964
+
965
+ def _find_disconnected_subgraphs(
966
+ inputs: list[frozenset[int]], output: frozenset[int]
967
+ ) -> list[frozenset[int]]:
968
+ """Finds disconnected subgraphs in the given list of inputs. Inputs are
969
+ connected if they share summation indices. Note: Disconnected subgraphs
970
+ can be contracted independently before forming outer products.
971
+
972
+ Parameters:
973
+ inputs: List of sets that represent the lhs side of the einsum subscript
974
+ output: Set that represents the rhs side of the overall einsum subscript
975
+
976
+ Returns:
977
+ subgraphs: List containing sets of indices for each subgraph
978
+
979
+ Examples:
980
+ ```python
981
+ _find_disconnected_subgraphs([set("ab"), set("c"), set("ad")], set("bd"))
982
+ #> [{0, 2}, {1}]
983
+
984
+ _find_disconnected_subgraphs([set("ab"), set("c"), set("ad")], set("abd"))
985
+ #> [{0}, {1}, {2}]
986
+ ```
987
+ """
988
+ subgraphs = []
989
+ unused_inputs = set(range(len(inputs)))
990
+
991
+ i_sum = frozenset.union(*inputs) - output # all summation indices
992
+
993
+ while len(unused_inputs) > 0:
994
+ g = set()
995
+ q = [unused_inputs.pop()]
996
+ while len(q) > 0:
997
+ j = q.pop()
998
+ g.add(j)
999
+ i_tmp = i_sum & inputs[j]
1000
+ n = {k for k in unused_inputs if len(i_tmp & inputs[k]) > 0}
1001
+ q.extend(n)
1002
+ unused_inputs.difference_update(n)
1003
+
1004
+ subgraphs.append(g)
1005
+
1006
+ return [frozenset(x) for x in subgraphs]
1007
+
1008
+
1009
+ def _bitmap_select(
1010
+ s: int, seq: list[frozenset[int]]
1011
+ ) -> Generator[frozenset[int], None, None]:
1012
+ """Select elements of ``seq`` which are marked by the bitmap set ``s``.
1013
+
1014
+ E.g.:
1015
+
1016
+ >>> list(_bitmap_select(0b11010, ['A', 'B', 'C', 'D', 'E']))
1017
+ ['B', 'D', 'E']
1018
+ """
1019
+ return (x for x, b in zip(seq, bin(s)[:1:-1], strict=False) if b == "1")
1020
+
1021
+
1022
+ def _dp_calc_legs(g, all_tensors, s, inputs, i1_cut_i2_wo_output, i1_union_i2):
1023
+ """Calculates the effective outer indices of the intermediate tensor
1024
+ corresponding to the subgraph ``s``.
1025
+ """
1026
+ # set of remaining tensors (=g-s)
1027
+ r = g & (all_tensors ^ s)
1028
+ # indices of remaining indices:
1029
+ if r:
1030
+ i_r = frozenset.union(*_bitmap_select(r, inputs))
1031
+ else:
1032
+ i_r = frozenset()
1033
+ # contraction indices:
1034
+ i_contract = i1_cut_i2_wo_output - i_r
1035
+ return i1_union_i2 - i_contract
1036
+
1037
+
1038
+ def _dp_compare_flops(
1039
+ cost1: int,
1040
+ cost2: int,
1041
+ i1_union_i2: set[int],
1042
+ size_dict: list[int],
1043
+ cost_cap: int,
1044
+ s1: int,
1045
+ s2: int,
1046
+ xn: dict[int, Any],
1047
+ g: int,
1048
+ all_tensors: int,
1049
+ inputs: list[frozenset[int]],
1050
+ i1_cut_i2_wo_output: set[int],
1051
+ memory_limit: int | None,
1052
+ contract1: int | tuple[int],
1053
+ contract2: int | tuple[int],
1054
+ get_ratio: Callable[[int, frozenset[int]], float] | None = None,
1055
+ ) -> None:
1056
+ """Performs the inner comparison of whether the two subgraphs (the bitmaps
1057
+ `s1` and `s2`) should be merged and added to the dynamic programming
1058
+ search. Will skip for a number of reasons:
1059
+
1060
+ 1. If the number of operations to form `s = s1 | s2` including previous
1061
+ contractions is above the cost-cap.
1062
+ 2. If we've already found a better way of making `s`.
1063
+ 3. If the intermediate tensor corresponding to `s` is going to break the
1064
+ memory limit.
1065
+
1066
+ When a ``get_ratio`` closure is provided, the step cost is scaled by the
1067
+ exact unique/dense symmetry ratio for the merged subset.
1068
+ """
1069
+ s = s1 | s2
1070
+ i = _dp_calc_legs(g, all_tensors, s, inputs, i1_cut_i2_wo_output, i1_union_i2)
1071
+
1072
+ dense_step = compute_size_by_dict(i1_union_i2, size_dict)
1073
+ if get_ratio is not None:
1074
+ step_cost = int(dense_step * get_ratio(s, i)) # type: ignore[arg-type]
1075
+ else:
1076
+ step_cost = dense_step
1077
+
1078
+ cost = cost1 + cost2 + step_cost
1079
+ if cost <= cost_cap:
1080
+ if s not in xn or cost < xn[s][1]:
1081
+ mem = compute_size_by_dict(i, size_dict)
1082
+ if memory_limit is None or mem <= memory_limit:
1083
+ xn[s] = (i, cost, (contract1, contract2))
1084
+
1085
+
1086
+ def _dp_compare_size(
1087
+ cost1: int,
1088
+ cost2: int,
1089
+ i1_union_i2: set[int],
1090
+ size_dict: list[int],
1091
+ cost_cap: int,
1092
+ s1: int,
1093
+ s2: int,
1094
+ xn: dict[int, Any],
1095
+ g: int,
1096
+ all_tensors: int,
1097
+ inputs: list[frozenset[int]],
1098
+ i1_cut_i2_wo_output: set[int],
1099
+ memory_limit: int | None,
1100
+ contract1: int | tuple[int],
1101
+ contract2: int | tuple[int],
1102
+ get_ratio: Callable[[int, frozenset[int]], float] | None = None,
1103
+ ) -> None:
1104
+ """Like `_dp_compare_flops` but sieves the potential contraction based
1105
+ on the size of the intermediate tensor created, rather than the number of
1106
+ operations, and so calculates that first.
1107
+ """
1108
+ s = s1 | s2
1109
+ i = _dp_calc_legs(g, all_tensors, s, inputs, i1_cut_i2_wo_output, i1_union_i2)
1110
+ mem = compute_size_by_dict(i, size_dict)
1111
+ if get_ratio is not None:
1112
+ mem = int(mem * get_ratio(s, i)) # type: ignore[arg-type]
1113
+ cost = max(cost1, cost2, mem)
1114
+ if cost <= cost_cap:
1115
+ if s not in xn or cost < xn[s][1]:
1116
+ if memory_limit is None or mem <= memory_limit:
1117
+ xn[s] = (i, cost, (contract1, contract2))
1118
+
1119
+
1120
+ def _dp_compare_write(
1121
+ cost1: int,
1122
+ cost2: int,
1123
+ i1_union_i2: set[int],
1124
+ size_dict: list[int],
1125
+ cost_cap: int,
1126
+ s1: int,
1127
+ s2: int,
1128
+ xn: dict[int, Any],
1129
+ g: int,
1130
+ all_tensors: int,
1131
+ inputs: list[frozenset[int]],
1132
+ i1_cut_i2_wo_output: set[int],
1133
+ memory_limit: int | None,
1134
+ contract1: int | tuple[int],
1135
+ contract2: int | tuple[int],
1136
+ get_ratio: Callable[[int, frozenset[int]], float] | None = None,
1137
+ ) -> None:
1138
+ """Like ``_dp_compare_flops`` but sieves the potential contraction based
1139
+ on the total size of memory created, rather than the number of
1140
+ operations, and so calculates that first.
1141
+ """
1142
+ s = s1 | s2
1143
+ i = _dp_calc_legs(g, all_tensors, s, inputs, i1_cut_i2_wo_output, i1_union_i2)
1144
+ mem = compute_size_by_dict(i, size_dict)
1145
+ if get_ratio is not None:
1146
+ mem = int(mem * get_ratio(s, i)) # type: ignore[arg-type]
1147
+ cost = cost1 + cost2 + mem
1148
+ if cost <= cost_cap:
1149
+ if s not in xn or cost < xn[s][1]:
1150
+ if memory_limit is None or mem <= memory_limit:
1151
+ xn[s] = (i, cost, (contract1, contract2))
1152
+
1153
+
1154
+ DEFAULT_COMBO_FACTOR = 64
1155
+
1156
+
1157
+ def _dp_compare_combo(
1158
+ cost1: int,
1159
+ cost2: int,
1160
+ i1_union_i2: set[int],
1161
+ size_dict: list[int],
1162
+ cost_cap: int,
1163
+ s1: int,
1164
+ s2: int,
1165
+ xn: dict[int, Any],
1166
+ g: int,
1167
+ all_tensors: int,
1168
+ inputs: list[frozenset[int]],
1169
+ i1_cut_i2_wo_output: set[int],
1170
+ memory_limit: int | None,
1171
+ contract1: int | tuple[int],
1172
+ contract2: int | tuple[int],
1173
+ factor: int | float = DEFAULT_COMBO_FACTOR,
1174
+ combine: Callable = sum,
1175
+ get_ratio: Callable[[int, frozenset[int]], float] | None = None,
1176
+ ) -> None:
1177
+ """Like ``_dp_compare_flops`` but sieves the potential contraction based
1178
+ on some combination of both the flops and size,.
1179
+ """
1180
+ s = s1 | s2
1181
+ i = _dp_calc_legs(g, all_tensors, s, inputs, i1_cut_i2_wo_output, i1_union_i2)
1182
+ mem = compute_size_by_dict(i, size_dict)
1183
+ f = compute_size_by_dict(i1_union_i2, size_dict)
1184
+ if get_ratio is not None:
1185
+ ratio = get_ratio(s, i) # type: ignore[arg-type]
1186
+ mem = int(mem * ratio)
1187
+ f = int(f * ratio)
1188
+ cost = cost1 + cost2 + combine((f, factor * mem))
1189
+ if cost <= cost_cap:
1190
+ if s not in xn or cost < xn[s][1]:
1191
+ if memory_limit is None or mem <= memory_limit:
1192
+ xn[s] = (i, cost, (contract1, contract2))
1193
+
1194
+
1195
+ minimize_finder = re.compile(r"(flops|size|write|combo|limit)-*(\d*)")
1196
+
1197
+
1198
+ @functools.lru_cache(128)
1199
+ def _parse_minimize(minimize: str | Callable) -> tuple[Callable, int | float]:
1200
+ """This works out what local scoring function to use for the dp algorithm
1201
+ as well as a `naive_scale` to account for the memory_limit checks.
1202
+ """
1203
+ if minimize == "flops":
1204
+ return _dp_compare_flops, 1
1205
+ elif minimize == "size":
1206
+ return _dp_compare_size, 1
1207
+ elif minimize == "write":
1208
+ return _dp_compare_write, 1
1209
+ elif callable(minimize):
1210
+ # default to naive_scale=inf for this and remaining options
1211
+ # as otherwise memory_limit check can cause problems
1212
+ return minimize, float("inf")
1213
+
1214
+ # parse out a customized value for the combination factor
1215
+ match = minimize_finder.fullmatch(minimize)
1216
+ if match is None:
1217
+ raise ValueError(f"Couldn't parse `minimize` value: {minimize}.")
1218
+
1219
+ minimize, custom_factor = match.groups()
1220
+ factor = float(custom_factor) if custom_factor else DEFAULT_COMBO_FACTOR
1221
+ if minimize == "combo":
1222
+ return functools.partial(_dp_compare_combo, factor=factor, combine=sum), float(
1223
+ "inf"
1224
+ )
1225
+ elif minimize == "limit":
1226
+ return functools.partial(_dp_compare_combo, factor=factor, combine=max), float(
1227
+ "inf"
1228
+ )
1229
+ else:
1230
+ raise ValueError(f"Couldn't parse `minimize` value: {minimize}.")
1231
+
1232
+
1233
+ def simple_tree_tuple(seq: Sequence[tuple[int, ...]]) -> tuple[Any, ...]:
1234
+ """Make a simple left to right binary tree out of iterable `seq`.
1235
+
1236
+ ```python
1237
+ tuple_nest([1, 2, 3, 4])
1238
+ #> (((1, 2), 3), 4)
1239
+ ```
1240
+
1241
+ """
1242
+ return functools.reduce(lambda x, y: (x, y), seq) # type: ignore[arg-type]
1243
+
1244
+
1245
+ def _dp_parse_out_single_term_ops(
1246
+ inputs: list[frozenset[int]],
1247
+ all_inds: tuple[str, ...],
1248
+ ind_counts: CounterType[str],
1249
+ ) -> tuple[list[frozenset[int]], list[tuple[int]], list[int | tuple[int]]]:
1250
+ """Take `inputs` and parse for single term index operations, i.e. where
1251
+ an index appears on one tensor and nowhere else.
1252
+
1253
+ If a term is completely reduced to a scalar in this way it can be removed
1254
+ to `inputs_done`. If only some indices can be summed then add a 'single
1255
+ term contraction' that will perform this summation.
1256
+ """
1257
+ i_single = frozenset(i for i, c in enumerate(all_inds) if ind_counts[c] == 1)
1258
+ inputs_parsed: list[frozenset[int]] = []
1259
+ inputs_done: list[tuple[int]] = []
1260
+ inputs_contractions: list[int | tuple[int]] = []
1261
+ for j, i in enumerate(inputs):
1262
+ i_reduced = i - i_single
1263
+ if (not i_reduced) and (len(i) > 0):
1264
+ # input reduced to scalar already - remove
1265
+ inputs_done.append((j,))
1266
+ else:
1267
+ # if the input has any index reductions, add single contraction
1268
+ inputs_parsed.append(i_reduced)
1269
+ inputs_contractions.append((j,) if i_reduced != i else j)
1270
+
1271
+ return inputs_parsed, inputs_done, inputs_contractions
1272
+
1273
+
1274
+ class DynamicProgramming(PathOptimizer):
1275
+ """Finds the optimal path of pairwise contractions without intermediate outer
1276
+ products based a dynamic programming approach presented in
1277
+ Phys. Rev. E 90, 033315 (2014) (the corresponding preprint is publicly
1278
+ available at https://arxiv.org/abs/1304.6112). This method is especially
1279
+ well-suited in the area of tensor network states, where it usually
1280
+ outperforms all the other optimization strategies.
1281
+
1282
+ This algorithm shows exponential scaling with the number of inputs
1283
+ in the worst case scenario (see example below). If the graph to be
1284
+ contracted consists of disconnected subgraphs, the algorithm scales
1285
+ linearly in the number of disconnected subgraphs and only exponentially
1286
+ with the number of inputs per subgraph.
1287
+
1288
+ Parameters:
1289
+ minimize: What to minimize:
1290
+ - 'flops' - minimize the number of flops
1291
+ - 'size' - minimize the size of the largest intermediate
1292
+ - 'write' - minimize the size of all intermediate tensors
1293
+ - 'combo' - minimize `flops + alpha * write` summed over intermediates, a default ratio of alpha=64
1294
+ is used, or it can be customized with `f'combo-{alpha}'`
1295
+ - 'limit' - minimize `max(flops, alpha * write)` summed over intermediates, a default ratio of alpha=64
1296
+ is used, or it can be customized with `f'limit-{alpha}'`
1297
+ - callable - a custom local cost function
1298
+
1299
+ cost_cap: How to implement cost-capping:
1300
+ - True - iteratively increase the cost-cap
1301
+ - False - implement no cost-cap at all
1302
+ - int - use explicit cost cap
1303
+
1304
+ search_outer: In rare circumstances the optimal contraction may involve an outer
1305
+ product, this option allows searching such contractions but may well
1306
+ slow down the path finding considerably on all but very small graphs.
1307
+ """
1308
+
1309
+ def __init__(
1310
+ self,
1311
+ minimize: str = "flops",
1312
+ cost_cap: bool | int = True,
1313
+ search_outer: bool = False,
1314
+ ) -> None:
1315
+ self.minimize = minimize
1316
+ self.search_outer = search_outer
1317
+ self.cost_cap = cost_cap
1318
+
1319
+ def __call__( # type: ignore[override]
1320
+ self,
1321
+ inputs_: list[ArrayIndexType],
1322
+ output_: ArrayIndexType,
1323
+ size_dict_: dict[str, int],
1324
+ memory_limit_: int | None = None,
1325
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
1326
+ ) -> PathType:
1327
+ """Parameters:
1328
+ inputs_: List of sets that represent the lhs side of the einsum subscript
1329
+ output_: Set that represents the rhs side of the overall einsum subscript
1330
+ size_dict_: Dictionary of index sizes
1331
+ memory_limit_: The maximum number of elements in a temporary array.
1332
+
1333
+ Returns:
1334
+ path: The contraction order (a list of tuples of ints).
1335
+
1336
+ Examples:
1337
+ ```python
1338
+ n_in = 3 # exponential scaling
1339
+ n_out = 2 # linear scaling
1340
+ s = dict()
1341
+ i_all = []
1342
+ for _ in range(n_out):
1343
+ i = [set() for _ in range(n_in)]
1344
+ for j in range(n_in):
1345
+ for k in range(j+1, n_in):
1346
+ c = oe.get_symbol(len(s))
1347
+ i[j].add(c)
1348
+ i[k].add(c)
1349
+ s[c] = 2
1350
+ i_all.extend(i)
1351
+ o = DynamicProgramming()
1352
+ o(i_all, set(), s)
1353
+ #> [(1, 2), (0, 4), (1, 2), (0, 2), (0, 1)]
1354
+ ```
1355
+ """
1356
+ _check_contraction, naive_scale = _parse_minimize(self.minimize)
1357
+ _check_outer = (lambda x: True) if self.search_outer else (lambda x: x)
1358
+
1359
+ ind_counts = Counter(itertools.chain(*inputs_, output_))
1360
+ all_inds = tuple(ind_counts)
1361
+
1362
+ # convert all indices to integers (makes set operations ~10 % faster)
1363
+ symbol2int = {c: j for j, c in enumerate(all_inds)}
1364
+ inputs = [frozenset(symbol2int[c] for c in i) for i in inputs_]
1365
+ output = frozenset(symbol2int[c] for c in output_)
1366
+ size_dict_canonical = {
1367
+ symbol2int[c]: v for c, v in size_dict_.items() if c in symbol2int
1368
+ }
1369
+ size_dict = [size_dict_canonical[j] for j in range(len(size_dict_canonical))]
1370
+ naive_cost = (
1371
+ naive_scale * len(inputs) * functools.reduce(operator.mul, size_dict, 1)
1372
+ )
1373
+
1374
+ inputs, inputs_done, inputs_contractions = _dp_parse_out_single_term_ops(
1375
+ inputs, all_inds, ind_counts
1376
+ )
1377
+
1378
+ if not inputs:
1379
+ # nothing left to do after single axis reductions!
1380
+ return _tree_to_sequence(simple_tree_tuple(inputs_done))
1381
+
1382
+ # a list of all necessary contraction expressions for each of the
1383
+ # disconnected subgraphs and their size
1384
+ subgraph_contractions = inputs_done
1385
+ subgraph_contractions_size = [1] * len(inputs_done)
1386
+
1387
+ if self.search_outer:
1388
+ # optimize everything together if we are considering outer products
1389
+ subgraphs = [frozenset(range(len(inputs)))]
1390
+ else:
1391
+ subgraphs = _find_disconnected_subgraphs(inputs, output)
1392
+
1393
+ # the bitmap set of all tensors is computed as it is needed to
1394
+ # compute set differences: s1 - s2 transforms into
1395
+ # s1 & (all_tensors ^ s2)
1396
+ all_tensors = (1 << len(inputs)) - 1
1397
+
1398
+ # Build a per-call bitmap->frozenset[int] converter for the oracle.
1399
+ # Maps DP bitmap `s` (bit j set iff tensor j is in the subset) to the
1400
+ # original operand-position frozenset that the oracle was built with.
1401
+ if symmetry_oracle is not None:
1402
+ _bts_cache: dict[int, frozenset[int]] = {}
1403
+
1404
+ def bitmap_to_subset(
1405
+ s: int, _cache: dict[int, frozenset[int]] = _bts_cache
1406
+ ) -> frozenset[int]:
1407
+ if s not in _cache:
1408
+ result: set[int] = set()
1409
+ for k in range(len(inputs)):
1410
+ if s >> k & 1:
1411
+ orig = inputs_contractions[k]
1412
+ result.add(orig if isinstance(orig, int) else orig[0])
1413
+ _cache[s] = frozenset(result)
1414
+ return _cache[s]
1415
+
1416
+ _ratio_cache: dict[int, float] = {}
1417
+
1418
+ def get_ratio(
1419
+ s: int,
1420
+ int_output_legs: frozenset[int],
1421
+ _cache: dict[int, float] = _ratio_cache,
1422
+ ) -> float:
1423
+ """Exact unique/dense ratio for the intermediate at DP bitmap s.
1424
+
1425
+ Lazily computed on first access and cached per subset. Returns
1426
+ 1.0 when the oracle reports no symmetry, or when the
1427
+ intermediate has no elements. The int<->str label translation
1428
+ happens once per cache miss and is amortized across all
1429
+ _dp_compare_* helper calls that subsequently reuse this
1430
+ subset's ratio.
1431
+
1432
+ all_inds[ix] is the inverse of symbol2int: the string label
1433
+ for int label ix. Only the labels in this intermediate need
1434
+ translation, keeping the per-miss work bounded.
1435
+ """
1436
+ cached = _cache.get(s, -1.0)
1437
+ if cached >= 0.0:
1438
+ return cached
1439
+ subset = bitmap_to_subset(s)
1440
+ subset_sym = symmetry_oracle.sym(subset)
1441
+ sym = subset_sym.output
1442
+ if sym is None:
1443
+ _cache[s] = 1.0
1444
+ return 1.0
1445
+ str_legs = frozenset(all_inds[ix] for ix in int_output_legs)
1446
+ str_size_dict = {all_inds[ix]: size_dict[ix] for ix in int_output_legs}
1447
+ dense = compute_size_by_dict(str_legs, str_size_dict)
1448
+ if dense <= 0:
1449
+ _cache[s] = 1.0
1450
+ return 1.0
1451
+ unique = unique_elements(str_legs, str_size_dict, perm_group=sym)
1452
+ ratio = unique / dense
1453
+ _cache[s] = ratio
1454
+ return ratio
1455
+
1456
+ else:
1457
+ bitmap_to_subset = None # type: ignore[assignment]
1458
+ get_ratio = None # type: ignore[assignment]
1459
+
1460
+ for g in subgraphs:
1461
+ # dynamic programming approach to compute x[n] for subgraph g;
1462
+ # x[n][set of n tensors] = (indices, cost, contraction)
1463
+ # the set of n tensors is represented by a bitmap: if bit j is 1,
1464
+ # tensor j is in the set, e.g. 0b100101 = {0,2,5}; set unions
1465
+ # (intersections) can then be computed by bitwise or (and);
1466
+ x: list[Any] = [None] * 2 + [{} for j in range(len(g) - 1)]
1467
+ x[1] = {1 << j: (inputs[j], 0, inputs_contractions[j]) for j in g}
1468
+
1469
+ # convert set of tensors g to a bitmap set:
1470
+ bitmap_g = functools.reduce(lambda x, y: x | y, (1 << j for j in g))
1471
+
1472
+ # try to find contraction with cost <= cost_cap and increase
1473
+ # cost_cap successively if no such contraction is found;
1474
+ # this is a major performance improvement; start with product of
1475
+ # output index dimensions as initial cost_cap
1476
+ subgraph_inds = frozenset.union(*_bitmap_select(bitmap_g, inputs))
1477
+ if self.cost_cap is True:
1478
+ cost_cap = compute_size_by_dict(subgraph_inds & output, size_dict)
1479
+ elif self.cost_cap is False:
1480
+ cost_cap = float("inf") # type: ignore
1481
+ else:
1482
+ cost_cap = self.cost_cap
1483
+ # set the factor to increase the cost by each iteration (ensure > 1)
1484
+ if len(subgraph_inds) == 0:
1485
+ cost_increment = 2
1486
+ else:
1487
+ cost_increment = max(min(map(size_dict.__getitem__, subgraph_inds)), 2)
1488
+
1489
+ while len(x[-1]) == 0:
1490
+ for n in range(2, len(x[1]) + 1):
1491
+ xn = x[n]
1492
+
1493
+ # try to combine solutions from x[m] and x[n-m]
1494
+ for m in range(1, n // 2 + 1):
1495
+ for s1, (i1, cost1, contract1) in x[m].items():
1496
+ for s2, (i2, cost2, contract2) in x[n - m].items():
1497
+ # can only merge if s1 and s2 are disjoint
1498
+ # and avoid e.g. s1={0}, s2={1} and s1={1}, s2={0}
1499
+ if (not s1 & s2) and (m != n - m or s1 < s2):
1500
+ i1_cut_i2_wo_output = (i1 & i2) - output
1501
+
1502
+ # maybe ignore outer products:
1503
+ if _check_outer(i1_cut_i2_wo_output):
1504
+ i1_union_i2 = i1 | i2
1505
+ _check_contraction(
1506
+ cost1,
1507
+ cost2,
1508
+ i1_union_i2,
1509
+ size_dict,
1510
+ cost_cap,
1511
+ s1,
1512
+ s2,
1513
+ xn,
1514
+ bitmap_g,
1515
+ all_tensors,
1516
+ inputs,
1517
+ i1_cut_i2_wo_output,
1518
+ memory_limit_,
1519
+ contract1,
1520
+ contract2,
1521
+ get_ratio=get_ratio,
1522
+ )
1523
+
1524
+ if (cost_cap > naive_cost) and (len(x[-1]) == 0):
1525
+ raise RuntimeError("No contraction found for given `memory_limit`.")
1526
+
1527
+ # increase cost cap for next iteration:
1528
+ cost_cap = cost_increment * cost_cap
1529
+
1530
+ i, cost, contraction = list(x[-1].values())[0]
1531
+ subgraph_contractions.append(contraction)
1532
+ subgraph_contractions_size.append(compute_size_by_dict(i, size_dict))
1533
+
1534
+ # sort the subgraph contractions by the size of the subgraphs in
1535
+ # ascending order (will give the cheapest contractions); note that
1536
+ # outer products should be performed pairwise (to use BLAS functions)
1537
+ subgraph_contractions = [
1538
+ subgraph_contractions[j]
1539
+ for j in sorted(
1540
+ range(len(subgraph_contractions_size)),
1541
+ key=subgraph_contractions_size.__getitem__,
1542
+ )
1543
+ ]
1544
+
1545
+ # build the final contraction tree
1546
+ tree = simple_tree_tuple(subgraph_contractions)
1547
+ return _tree_to_sequence(tree)
1548
+
1549
+
1550
+ def dynamic_programming(
1551
+ inputs: list[ArrayIndexType],
1552
+ output: ArrayIndexType,
1553
+ size_dict: dict[str, int],
1554
+ memory_limit: int | None = None,
1555
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
1556
+ **kwargs: Any,
1557
+ ) -> PathType:
1558
+ optimizer = DynamicProgramming(**kwargs)
1559
+ return optimizer(
1560
+ inputs, output, size_dict, memory_limit, symmetry_oracle=symmetry_oracle
1561
+ )
1562
+
1563
+
1564
+ _AUTO_CHOICES = {}
1565
+ for i in range(1, 5):
1566
+ _AUTO_CHOICES[i] = optimal
1567
+ for i in range(5, 7):
1568
+ _AUTO_CHOICES[i] = branch_all
1569
+ for i in range(7, 9):
1570
+ _AUTO_CHOICES[i] = branch_2
1571
+ for i in range(9, 15):
1572
+ _AUTO_CHOICES[i] = branch_1
1573
+
1574
+
1575
+ def auto(
1576
+ inputs: list[ArrayIndexType],
1577
+ output: ArrayIndexType,
1578
+ size_dict: dict[str, int],
1579
+ memory_limit: int | None = None,
1580
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
1581
+ ) -> PathType:
1582
+ """Auto-select based on number of inputs. All routed optimizers
1583
+ accept ``symmetry_oracle``; no silent fallback."""
1584
+ fn = _AUTO_CHOICES.get(len(inputs), greedy)
1585
+ return fn(
1586
+ inputs,
1587
+ output,
1588
+ size_dict,
1589
+ memory_limit,
1590
+ symmetry_oracle=symmetry_oracle,
1591
+ )
1592
+
1593
+
1594
+ _AUTO_HQ_CHOICES = {}
1595
+ for i in range(1, 6):
1596
+ _AUTO_HQ_CHOICES[i] = optimal
1597
+ for i in range(6, 17):
1598
+ _AUTO_HQ_CHOICES[i] = dynamic_programming
1599
+
1600
+
1601
+ def auto_hq(
1602
+ inputs: list[ArrayIndexType],
1603
+ output: ArrayIndexType,
1604
+ size_dict: dict[str, int],
1605
+ memory_limit: int | None = None,
1606
+ symmetry_oracle: SubgraphSymmetryOracle | None = None,
1607
+ ) -> PathType:
1608
+ """Auto-HQ selection based on number of inputs. All routed
1609
+ optimizers accept ``symmetry_oracle``; no silent fallback."""
1610
+ from ._path_random import random_greedy_128
1611
+
1612
+ fn = _AUTO_HQ_CHOICES.get(len(inputs), random_greedy_128)
1613
+ return fn(
1614
+ inputs,
1615
+ output,
1616
+ size_dict,
1617
+ memory_limit,
1618
+ symmetry_oracle=symmetry_oracle,
1619
+ )
1620
+
1621
+
1622
+ _PATH_OPTIONS: dict[str, PathSearchFunctionType] = {
1623
+ "auto": auto,
1624
+ "auto-hq": auto_hq,
1625
+ "optimal": optimal,
1626
+ "branch-all": branch_all,
1627
+ "branch-2": branch_2,
1628
+ "branch-1": branch_1,
1629
+ "greedy": greedy,
1630
+ "eager": greedy,
1631
+ "opportunistic": greedy,
1632
+ "dp": dynamic_programming,
1633
+ "dynamic-programming": dynamic_programming,
1634
+ }
1635
+
1636
+
1637
+ def register_path_fn(name: str, fn: PathSearchFunctionType) -> None:
1638
+ """Add path finding function ``fn`` as an option with ``name``."""
1639
+ if name in _PATH_OPTIONS:
1640
+ raise KeyError(f"Path optimizer '{name}' already exists.")
1641
+
1642
+ _PATH_OPTIONS[name.lower()] = fn
1643
+
1644
+
1645
+ def get_path_fn(path_type: str) -> PathSearchFunctionType:
1646
+ """Get the correct path finding function from str ``path_type``."""
1647
+ path_type = path_type.lower()
1648
+ if path_type not in _PATH_OPTIONS:
1649
+ raise KeyError(
1650
+ f"Path optimizer '{path_type}' not found, valid options are {set(_PATH_OPTIONS.keys())}."
1651
+ )
1652
+
1653
+ return _PATH_OPTIONS[path_type]