codekingpro/portable-devtools
114k
1"""
2Implementation of optimized einsum.
3
4"""
5import functools
6import itertools
7import operator
8
9from numpy._core.multiarray import c_einsum, matmul
10from numpy._core.numeric import asanyarray, reshape
11from numpy._core.overrides import array_function_dispatch
12from numpy._core.umath import multiply
13
14__all__ = ['einsum', 'einsum_path']
15
16# importing string for string.ascii_letters would be too slow
17# the first import before caching has been measured to take 800 µs (#23777)
18# imports begin with uppercase to mimic ASCII values to avoid sorting issues
19einsum_symbols = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
20einsum_symbols_set = set(einsum_symbols)
21
22
23def _flop_count(idx_contraction, inner, num_terms, size_dictionary):
24 """
25 Computes the number of FLOPS in the contraction.
26
27 Parameters
28 ----------
29 idx_contraction : iterable
30 The indices involved in the contraction
31 inner : bool
32 Does this contraction require an inner product?
33 num_terms : int
34 The number of terms in a contraction
35 size_dictionary : dict
36 The size of each of the indices in idx_contraction
37
38 Returns
39 -------
40 flop_count : int
41 The total number of FLOPS required for the contraction.
42
43 Examples
44 --------
45
46 >>> _flop_count('abc', False, 1, {'a': 2, 'b':3, 'c':5})
47 30
48
49 >>> _flop_count('abc', True, 2, {'a': 2, 'b':3, 'c':5})
50 60
51
52 """
53
54 overall_size = _compute_size_by_dict(idx_contraction, size_dictionary)
55 op_factor = max(1, num_terms - 1)
56 if inner:
57 op_factor += 1
58
59 return overall_size * op_factor
60
61def _compute_size_by_dict(indices, idx_dict):
62 """
63 Computes the product of the elements in indices based on the dictionary
64 idx_dict.
65
66 Parameters
67 ----------
68 indices : iterable
69 Indices to base the product on.
70 idx_dict : dictionary
71 Dictionary of index sizes
72
73 Returns
74 -------
75 ret : int
76 The resulting product.
77
78 Examples
79 --------
80 >>> _compute_size_by_dict('abbc', {'a': 2, 'b':3, 'c':5})
81 90
82
83 """
84 ret = 1
85 for i in indices:
86 ret *= idx_dict[i]
87 return ret
88
89
90def _find_contraction(positions, input_sets, output_set):
91 """
92 Finds the contraction for a given set of input and output sets.
93
94 Parameters
95 ----------
96 positions : iterable
97 Integer positions of terms used in the contraction.
98 input_sets : list
99 List of sets that represent the lhs side of the einsum subscript
100 output_set : set
101 Set that represents the rhs side of the overall einsum subscript
102
103 Returns
104 -------
105 new_result : set
106 The indices of the resulting contraction
107 remaining : list
108 List of sets that have not been contracted, the new set is appended to
109 the end of this list
110 idx_removed : set
111 Indices removed from the entire contraction
112 idx_contraction : set
113 The indices used in the current contraction
114
115 Examples
116 --------
117
118 # A simple dot product test case
119 >>> pos = (0, 1)
120 >>> isets = [set('ab'), set('bc')]
121 >>> oset = set('ac')
122 >>> _find_contraction(pos, isets, oset)
123 ({'a', 'c'}, [{'a', 'c'}], {'b'}, {'a', 'b', 'c'})
124
125 # A more complex case with additional terms in the contraction
126 >>> pos = (0, 2)
127 >>> isets = [set('abd'), set('ac'), set('bdc')]
128 >>> oset = set('ac')
129 >>> _find_contraction(pos, isets, oset)
130 ({'a', 'c'}, [{'a', 'c'}, {'a', 'c'}], {'b', 'd'}, {'a', 'b', 'c', 'd'})
131 """
132
133 idx_contract = set()
134 idx_remain = output_set.copy()
135 remaining = []
136 for ind, value in enumerate(input_sets):
137 if ind in positions:
138 idx_contract |= value
139 else:
140 remaining.append(value)
141 idx_remain |= value
142
143 new_result = idx_remain & idx_contract
144 idx_removed = (idx_contract - new_result)
145 remaining.append(new_result)
146
147 return (new_result, remaining, idx_removed, idx_contract)
148
149
150def _optimal_path(input_sets, output_set, idx_dict, memory_limit):
151 """
152 Computes all possible pair contractions, sieves the results based
153 on ``memory_limit`` and returns the lowest cost path. This algorithm
154 scales factorial with respect to the elements in the list ``input_sets``.
155
156 Parameters
157 ----------
158 input_sets : list
159 List of sets that represent the lhs side of the einsum subscript
160 output_set : set
161 Set that represents the rhs side of the overall einsum subscript
162 idx_dict : dictionary
163 Dictionary of index sizes
164 memory_limit : int
165 The maximum number of elements in a temporary array
166
167 Returns
168 -------
169 path : list
170 The optimal contraction order within the memory limit constraint.
171
172 Examples
173 --------
174 >>> isets = [set('abd'), set('ac'), set('bdc')]
175 >>> oset = set()
176 >>> idx_sizes = {'a': 1, 'b':2, 'c':3, 'd':4}
177 >>> _optimal_path(isets, oset, idx_sizes, 5000)
178 [(0, 2), (0, 1)]
179 """
180
181 full_results = [(0, [], input_sets)]
182 for iteration in range(len(input_sets) - 1):
183 iter_results = []
184
185 # Compute all unique pairs
186 for curr in full_results:
187 cost, positions, remaining = curr
188 for con in itertools.combinations(
189 range(len(input_sets) - iteration), 2
190 ):
191
192 # Find the contraction
193 cont = _find_contraction(con, remaining, output_set)
194 new_result, new_input_sets, idx_removed, idx_contract = cont
195
196 # Sieve the results based on memory_limit
197 new_size = _compute_size_by_dict(new_result, idx_dict)
198 if new_size > memory_limit:
199 continue
200
201 # Build (total_cost, positions, indices_remaining)
202 total_cost = cost + _flop_count(
203 idx_contract, idx_removed, len(con), idx_dict
204 )
205 new_pos = positions + [con]
206 iter_results.append((total_cost, new_pos, new_input_sets))
207
208 # Update combinatorial list, if we did not find anything return best
209 # path + remaining contractions
210 if iter_results:
211 full_results = iter_results
212 else:
213 path = min(full_results, key=lambda x: x[0])[1]
214 path += [tuple(range(len(input_sets) - iteration))]
215 return path
216
217 # If we have not found anything return single einsum contraction
218 if len(full_results) == 0:
219 return [tuple(range(len(input_sets)))]
220
221 path = min(full_results, key=lambda x: x[0])[1]
222 return path
223
224def _parse_possible_contraction(
225 positions, input_sets, output_set, idx_dict,
226 memory_limit, path_cost, naive_cost
227 ):
228 """Compute the cost (removed size + flops) and resultant indices for
229 performing the contraction specified by ``positions``.
230
231 Parameters
232 ----------
233 positions : tuple of int
234 The locations of the proposed tensors to contract.
235 input_sets : list of sets
236 The indices found on each tensors.
237 output_set : set
238 The output indices of the expression.
239 idx_dict : dict
240 Mapping of each index to its size.
241 memory_limit : int
242 The total allowed size for an intermediary tensor.
243 path_cost : int
244 The contraction cost so far.
245 naive_cost : int
246 The cost of the unoptimized expression.
247
248 Returns
249 -------
250 cost : (int, int)
251 A tuple containing the size of any indices removed, and the flop cost.
252 positions : tuple of int
253 The locations of the proposed tensors to contract.
254 new_input_sets : list of sets
255 The resulting new list of indices if this proposed contraction
256 is performed.
257
258 """
259
260 # Find the contraction
261 contract = _find_contraction(positions, input_sets, output_set)
262 idx_result, new_input_sets, idx_removed, idx_contract = contract
263
264 # Sieve the results based on memory_limit
265 new_size = _compute_size_by_dict(idx_result, idx_dict)
266 if new_size > memory_limit:
267 return None
268
269 # Build sort tuple
270 old_sizes = (
271 _compute_size_by_dict(input_sets[p], idx_dict) for p in positions
272 )
273 removed_size = sum(old_sizes) - new_size
274
275 # NB: removed_size used to be just the size of any removed indices i.e.:
276 # helpers.compute_size_by_dict(idx_removed, idx_dict)
277 cost = _flop_count(idx_contract, idx_removed, len(positions), idx_dict)
278 sort = (-removed_size, cost)
279
280 # Sieve based on total cost as well
281 if (path_cost + cost) > naive_cost:
282 return None
283
284 # Add contraction to possible choices
285 return [sort, positions, new_input_sets]
286
287
288def _update_other_results(results, best):
289 """Update the positions and provisional input_sets of ``results``
290 based on performing the contraction result ``best``. Remove any
291 involving the tensors contracted.
292
293 Parameters
294 ----------
295 results : list
296 List of contraction results produced by
297 ``_parse_possible_contraction``.
298 best : list
299 The best contraction of ``results`` i.e. the one that
300 will be performed.
301
302 Returns
303 -------
304 mod_results : list
305 The list of modified results, updated with outcome of
306 ``best`` contraction.
307 """
308
309 best_con = best[1]
310 bx, by = best_con
311 mod_results = []
312
313 for cost, (x, y), con_sets in results:
314
315 # Ignore results involving tensors just contracted
316 if x in best_con or y in best_con:
317 continue
318
319 # Update the input_sets
320 del con_sets[by - int(by > x) - int(by > y)]
321 del con_sets[bx - int(bx > x) - int(bx > y)]
322 con_sets.insert(-1, best[2][-1])
323
324 # Update the position indices
325 mod_con = x - int(x > bx) - int(x > by), y - int(y > bx) - int(y > by)
326 mod_results.append((cost, mod_con, con_sets))
327
328 return mod_results
329
330def _greedy_path(input_sets, output_set, idx_dict, memory_limit):
331 """
332 Finds the path by contracting the best pair until the input list is
333 exhausted. The best pair is found by minimizing the tuple
334 ``(-prod(indices_removed), cost)``. What this amounts to is prioritizing
335 matrix multiplication or inner product operations, then Hadamard like
336 operations, and finally outer operations. Outer products are limited by
337 ``memory_limit``. This algorithm scales cubically with respect to the
338 number of elements in the list ``input_sets``.
339
340 Parameters
341 ----------
342 input_sets : list
343 List of sets that represent the lhs side of the einsum subscript
344 output_set : set
345 Set that represents the rhs side of the overall einsum subscript
346 idx_dict : dictionary
347 Dictionary of index sizes
348 memory_limit : int
349 The maximum number of elements in a temporary array
350
351 Returns
352 -------
353 path : list
354 The greedy contraction order within the memory limit constraint.
355
356 Examples
357 --------
358 >>> isets = [set('abd'), set('ac'), set('bdc')]
359 >>> oset = set()
360 >>> idx_sizes = {'a': 1, 'b':2, 'c':3, 'd':4}
361 >>> _greedy_path(isets, oset, idx_sizes, 5000)
362 [(0, 2), (0, 1)]
363 """
364
365 # Handle trivial cases that leaked through
366 if len(input_sets) == 1:
367 return [(0,)]
368 elif len(input_sets) == 2:
369 return [(0, 1)]
370
371 # Build up a naive cost
372 contract = _find_contraction(
373 range(len(input_sets)), input_sets, output_set
374 )
375 idx_result, new_input_sets, idx_removed, idx_contract = contract
376 naive_cost = _flop_count(
377 idx_contract, idx_removed, len(input_sets), idx_dict
378 )
379
380 # Initially iterate over all pairs
381 comb_iter = itertools.combinations(range(len(input_sets)), 2)
382 known_contractions = []
383
384 path_cost = 0
385 path = []
386
387 for iteration in range(len(input_sets) - 1):
388
389 # Iterate over all pairs on the first step, only previously
390 # found pairs on subsequent steps
391 for positions in comb_iter:
392
393 # Always initially ignore outer products
394 if input_sets[positions[0]].isdisjoint(input_sets[positions[1]]):
395 continue
396
397 result = _parse_possible_contraction(
398 positions, input_sets, output_set, idx_dict,
399 memory_limit, path_cost, naive_cost
400 )
401 if result is not None:
402 known_contractions.append(result)
403
404 # If we do not have a inner contraction, rescan pairs
405 # including outer products
406 if len(known_contractions) == 0:
407
408 # Then check the outer products
409 for positions in itertools.combinations(
410 range(len(input_sets)), 2
411 ):
412 result = _parse_possible_contraction(
413 positions, input_sets, output_set, idx_dict,
414 memory_limit, path_cost, naive_cost
415 )
416 if result is not None:
417 known_contractions.append(result)
418
419 # If we still did not find any remaining contractions,
420 # default back to einsum like behavior
421 if len(known_contractions) == 0:
422 path.append(tuple(range(len(input_sets))))
423 break
424
425 # Sort based on first index
426 best = min(known_contractions, key=lambda x: x[0])
427
428 # Now propagate as many unused contractions as possible
429 # to the next iteration
430 known_contractions = _update_other_results(known_contractions, best)
431
432 # Next iteration only compute contractions with the new tensor
433 # All other contractions have been accounted for
434 input_sets = best[2]
435 new_tensor_pos = len(input_sets) - 1
436 comb_iter = ((i, new_tensor_pos) for i in range(new_tensor_pos))
437
438 # Update path and total cost
439 path.append(best[1])
440 path_cost += best[0][1]
441
442 return path
443
444
445def _parse_einsum_input(operands):
446 """
447 A reproduction of einsum c side einsum parsing in python.
448
449 Returns
450 -------
451 input_strings : str
452 Parsed input strings
453 output_string : str
454 Parsed output string
455 operands : list of array_like
456 The operands to use in the numpy contraction
457
458 Examples
459 --------
460 The operand list is simplified to reduce printing:
461
462 >>> np.random.seed(123)
463 >>> a = np.random.rand(4, 4)
464 >>> b = np.random.rand(4, 4, 4)
465 >>> _parse_einsum_input(('...a,...a->...', a, b))
466 ('za,xza', 'xz', [a, b]) # may vary
467
468 >>> _parse_einsum_input((a, [Ellipsis, 0], b, [Ellipsis, 0]))
469 ('za,xza', 'xz', [a, b]) # may vary
470 """
471
472 if len(operands) == 0:
473 raise ValueError("No input operands")
474
475 if isinstance(operands[0], str):
476 subscripts = operands[0].replace(" ", "")
477 operands = [asanyarray(v) for v in operands[1:]]
478
479 # Ensure all characters are valid
480 for s in subscripts:
481 if s in '.,->':
482 continue
483 if s not in einsum_symbols:
484 raise ValueError(f"Character {s} is not a valid symbol.")
485
486 else:
487 tmp_operands = list(operands)
488 operand_list = []
489 subscript_list = []
490 for p in range(len(operands) // 2):
491 operand_list.append(tmp_operands.pop(0))
492 subscript_list.append(tmp_operands.pop(0))
493
494 output_list = tmp_operands[-1] if len(tmp_operands) else None
495 operands = [asanyarray(v) for v in operand_list]
496 subscripts = ""
497 last = len(subscript_list) - 1
498 for num, sub in enumerate(subscript_list):
499 for s in sub:
500 if s is Ellipsis:
501 subscripts += "..."
502 else:
503 try:
504 s = operator.index(s)
505 except TypeError as e:
506 raise TypeError(
507 "For this input type lists must contain "
508 "either int or Ellipsis"
509 ) from e
510 subscripts += einsum_symbols[s]
511 if num != last:
512 subscripts += ","
513
514 if output_list is not None:
515 subscripts += "->"
516 for s in output_list:
517 if s is Ellipsis:
518 subscripts += "..."
519 else:
520 try:
521 s = operator.index(s)
522 except TypeError as e:
523 raise TypeError(
524 "For this input type lists must contain "
525 "either int or Ellipsis"
526 ) from e
527 subscripts += einsum_symbols[s]
528 # Check for proper "->"
529 if ("-" in subscripts) or (">" in subscripts):
530 invalid = (subscripts.count("-") > 1) or (subscripts.count(">") > 1)
531 if invalid or (subscripts.count("->") != 1):
532 raise ValueError("Subscripts can only contain one '->'.")
533
534 # Parse ellipses
535 if "." in subscripts:
536 used = subscripts.replace(".", "").replace(",", "").replace("->", "")
537 unused = list(einsum_symbols_set - set(used))
538 ellipse_inds = "".join(unused)
539 longest = 0
540
541 if "->" in subscripts:
542 input_tmp, output_sub = subscripts.split("->")
543 split_subscripts = input_tmp.split(",")
544 out_sub = True
545 else:
546 split_subscripts = subscripts.split(',')
547 out_sub = False
548
549 for num, sub in enumerate(split_subscripts):
550 if "." in sub:
551 if (sub.count(".") != 3) or (sub.count("...") != 1):
552 raise ValueError("Invalid Ellipses.")
553
554 # Take into account numerical values
555 if operands[num].shape == ():
556 ellipse_count = 0
557 else:
558 ellipse_count = max(operands[num].ndim, 1)
559 ellipse_count -= (len(sub) - 3)
560
561 if ellipse_count > longest:
562 longest = ellipse_count
563
564 if ellipse_count < 0:
565 raise ValueError("Ellipses lengths do not match.")
566 elif ellipse_count == 0:
567 split_subscripts[num] = sub.replace('...', '')
568 else:
569 rep_inds = ellipse_inds[-ellipse_count:]
570 split_subscripts[num] = sub.replace('...', rep_inds)
571
572 subscripts = ",".join(split_subscripts)
573 if longest == 0:
574 out_ellipse = ""
575 else:
576 out_ellipse = ellipse_inds[-longest:]
577
578 if out_sub:
579 subscripts += "->" + output_sub.replace("...", out_ellipse)
580 else:
581 # Special care for outputless ellipses
582 output_subscript = ""
583 tmp_subscripts = subscripts.replace(",", "")
584 for s in sorted(set(tmp_subscripts)):
585 if s not in (einsum_symbols):
586 raise ValueError(f"Character {s} is not a valid symbol.")
587 if tmp_subscripts.count(s) == 1:
588 output_subscript += s
589 normal_inds = ''.join(sorted(set(output_subscript) -
590 set(out_ellipse)))
591
592 subscripts += "->" + out_ellipse + normal_inds
593
594 # Build output string if does not exist
595 if "->" in subscripts:
596 input_subscripts, output_subscript = subscripts.split("->")
597 else:
598 input_subscripts = subscripts
599 # Build output subscripts
600 tmp_subscripts = subscripts.replace(",", "")
601 output_subscript = ""
602 for s in sorted(set(tmp_subscripts)):
603 if s not in einsum_symbols:
604 raise ValueError(f"Character {s} is not a valid symbol.")
605 if tmp_subscripts.count(s) == 1:
606 output_subscript += s
607
608 # Make sure output subscripts are in the input
609 for char in output_subscript:
610 if output_subscript.count(char) != 1:
611 raise ValueError("Output character %s appeared more than once in "
612 "the output." % char)
613 if char not in input_subscripts:
614 raise ValueError(f"Output character {char} did not appear in the input")
615
616 # Make sure number operands is equivalent to the number of terms
617 if len(input_subscripts.split(',')) != len(operands):
618 raise ValueError("Number of einsum subscripts must be equal to the "
619 "number of operands.")
620
621 return (input_subscripts, output_subscript, operands)
622
623
624def _einsum_path_dispatcher(*operands, optimize=None, einsum_call=None):
625 # NOTE: technically, we should only dispatch on array-like arguments, not
626 # subscripts (given as strings). But separating operands into
627 # arrays/subscripts is a little tricky/slow (given einsum's two supported
628 # signatures), so as a practical shortcut we dispatch on everything.
629 # Strings will be ignored for dispatching since they don't define
630 # __array_function__.
631 return operands
632
633
634@array_function_dispatch(_einsum_path_dispatcher, module='numpy')
635def einsum_path(*operands, optimize='greedy', einsum_call=False):
636 """
637 einsum_path(subscripts, *operands, optimize='greedy')
638
639 Evaluates the lowest cost contraction order for an einsum expression by
640 considering the creation of intermediate arrays.
641
642 Parameters
643 ----------
644 subscripts : str
645 Specifies the subscripts for summation.
646 *operands : list of array_like
647 These are the arrays for the operation.
648 optimize : {bool, list, tuple, 'greedy', 'optimal'}
649 Choose the type of path. If a tuple is provided, the second argument is
650 assumed to be the maximum intermediate size created. If only a single
651 argument is provided the largest input or output array size is used
652 as a maximum intermediate size.
653
654 * if a list is given that starts with ``einsum_path``, uses this as the
655 contraction path
656 * if False no optimization is taken
657 * if True defaults to the 'greedy' algorithm
658 * 'optimal' An algorithm that combinatorially explores all possible
659 ways of contracting the listed tensors and chooses the least costly
660 path. Scales exponentially with the number of terms in the
661 contraction.
662 * 'greedy' An algorithm that chooses the best pair contraction
663 at each step. Effectively, this algorithm searches the largest inner,
664 Hadamard, and then outer products at each step. Scales cubically with
665 the number of terms in the contraction. Equivalent to the 'optimal'
666 path for most contractions.
667
668 Default is 'greedy'.
669
670 Returns
671 -------
672 path : list of tuples
673 A list representation of the einsum path.
674 string_repr : str
675 A printable representation of the einsum path.
676
677 Notes
678 -----
679 The resulting path indicates which terms of the input contraction should be
680 contracted first, the result of this contraction is then appended to the
681 end of the contraction list. This list can then be iterated over until all
682 intermediate contractions are complete.
683
684 See Also
685 --------
686 einsum, linalg.multi_dot
687
688 Examples
689 --------
690
691 We can begin with a chain dot example. In this case, it is optimal to
692 contract the ``b`` and ``c`` tensors first as represented by the first
693 element of the path ``(1, 2)``. The resulting tensor is added to the end
694 of the contraction and the remaining contraction ``(0, 1)`` is then
695 completed.
696
697 >>> np.random.seed(123)
698 >>> a = np.random.rand(2, 2)
699 >>> b = np.random.rand(2, 5)
700 >>> c = np.random.rand(5, 2)
701 >>> path_info = np.einsum_path('ij,jk,kl->il', a, b, c, optimize='greedy')
702 >>> print(path_info[0])
703 ['einsum_path', (1, 2), (0, 1)]
704 >>> print(path_info[1])
705 Complete contraction: ij,jk,kl->il # may vary
706 Naive scaling: 4
707 Optimized scaling: 3
708 Naive FLOP count: 1.600e+02
709 Optimized FLOP count: 5.600e+01
710 Theoretical speedup: 2.857
711 Largest intermediate: 4.000e+00 elements
712 -------------------------------------------------------------------------
713 scaling current remaining
714 -------------------------------------------------------------------------
715 3 kl,jk->jl ij,jl->il
716 3 jl,ij->il il->il
717
718
719 A more complex index transformation example.
720
721 >>> I = np.random.rand(10, 10, 10, 10)
722 >>> C = np.random.rand(10, 10)
723 >>> path_info = np.einsum_path('ea,fb,abcd,gc,hd->efgh', C, C, I, C, C,
724 ... optimize='greedy')
725
726 >>> print(path_info[0])
727 ['einsum_path', (0, 2), (0, 3), (0, 2), (0, 1)]
728 >>> print(path_info[1])
729 Complete contraction: ea,fb,abcd,gc,hd->efgh # may vary
730 Naive scaling: 8
731 Optimized scaling: 5
732 Naive FLOP count: 8.000e+08
733 Optimized FLOP count: 8.000e+05
734 Theoretical speedup: 1000.000
735 Largest intermediate: 1.000e+04 elements
736 --------------------------------------------------------------------------
737 scaling current remaining
738 --------------------------------------------------------------------------
739 5 abcd,ea->bcde fb,gc,hd,bcde->efgh
740 5 bcde,fb->cdef gc,hd,cdef->efgh
741 5 cdef,gc->defg hd,defg->efgh
742 5 defg,hd->efgh efgh->efgh
743 """
744
745 # Figure out what the path really is
746 path_type = optimize
747 if path_type is True:
748 path_type = 'greedy'
749 if path_type is None:
750 path_type = False
751
752 explicit_einsum_path = False
753 memory_limit = None
754
755 # No optimization or a named path algorithm
756 if (path_type is False) or isinstance(path_type, str):
757 pass
758
759 # Given an explicit path
760 elif len(path_type) and (path_type[0] == 'einsum_path'):
761 explicit_einsum_path = True
762
763 # Path tuple with memory limit
764 elif ((len(path_type) == 2) and isinstance(path_type[0], str) and
765 isinstance(path_type[1], (int, float))):
766 memory_limit = int(path_type[1])
767 path_type = path_type[0]
768
769 else:
770 raise TypeError(f"Did not understand the path: {str(path_type)}")
771
772 # Hidden option, only einsum should call this
773 einsum_call_arg = einsum_call
774
775 # Python side parsing
776 input_subscripts, output_subscript, operands = (
777 _parse_einsum_input(operands)
778 )
779
780 # Build a few useful list and sets
781 input_list = input_subscripts.split(',')
782 num_inputs = len(input_list)
783 input_sets = [set(x) for x in input_list]
784 output_set = set(output_subscript)
785 indices = set(input_subscripts.replace(',', ''))
786 num_indices = len(indices)
787
788 # Get length of each unique dimension and ensure all dimensions are correct
789 dimension_dict = {}
790 for tnum, term in enumerate(input_list):
791 sh = operands[tnum].shape
792 if len(sh) != len(term):
793 raise ValueError("Einstein sum subscript %s does not contain the "
794 "correct number of indices for operand %d."
795 % (input_subscripts[tnum], tnum))
796 for cnum, char in enumerate(term):
797 dim = sh[cnum]
798
799 if char in dimension_dict.keys():
800 # For broadcasting cases we always want the largest dim size
801 if dimension_dict[char] == 1:
802 dimension_dict[char] = dim
803 elif dim not in (1, dimension_dict[char]):
804 raise ValueError("Size of label '%s' for operand %d (%d) "
805 "does not match previous terms (%d)."
806 % (char, tnum, dimension_dict[char], dim))
807 else:
808 dimension_dict[char] = dim
809
810 # Compute size of each input array plus the output array
811 size_list = [_compute_size_by_dict(term, dimension_dict)
812 for term in input_list + [output_subscript]]
813 max_size = max(size_list)
814
815 if memory_limit is None:
816 memory_arg = max_size
817 else:
818 memory_arg = memory_limit
819
820 # Compute the path
821 if explicit_einsum_path:
822 path = path_type[1:]
823 elif (
824 (path_type is False)
825 or (num_inputs in [1, 2])
826 or (indices == output_set)
827 ):
828 # Nothing to be optimized, leave it to einsum
829 path = [tuple(range(num_inputs))]
830 elif path_type == "greedy":
831 path = _greedy_path(
832 input_sets, output_set, dimension_dict, memory_arg
833 )
834 elif path_type == "optimal":
835 path = _optimal_path(
836 input_sets, output_set, dimension_dict, memory_arg
837 )
838 else:
839 raise KeyError("Path name %s not found", path_type)
840
841 cost_list, scale_list, size_list, contraction_list = [], [], [], []
842
843 # Build contraction tuple (positions, gemm, einsum_str, remaining)
844 for cnum, contract_inds in enumerate(path):
845 # Make sure we remove inds from right to left
846 contract_inds = tuple(sorted(contract_inds, reverse=True))
847
848 contract = _find_contraction(contract_inds, input_sets, output_set)
849 out_inds, input_sets, idx_removed, idx_contract = contract
850
851 if not einsum_call_arg:
852 # these are only needed for printing info
853 cost = _flop_count(
854 idx_contract, idx_removed, len(contract_inds), dimension_dict
855 )
856 cost_list.append(cost)
857 scale_list.append(len(idx_contract))
858 size_list.append(_compute_size_by_dict(out_inds, dimension_dict))
859
860 tmp_inputs = []
861 for x in contract_inds:
862 tmp_inputs.append(input_list.pop(x))
863
864 # Last contraction
865 if (cnum - len(path)) == -1:
866 idx_result = output_subscript
867 else:
868 sort_result = [(dimension_dict[ind], ind) for ind in out_inds]
869 idx_result = "".join([x[1] for x in sorted(sort_result)])
870
871 input_list.append(idx_result)
872 einsum_str = ",".join(tmp_inputs) + "->" + idx_result
873
874 contraction = (contract_inds, einsum_str, input_list[:])
875 contraction_list.append(contraction)
876
877 if len(input_list) != 1:
878 # Explicit "einsum_path" is usually trusted, but we detect this kind of
879 # mistake in order to prevent from returning an intermediate value.
880 raise RuntimeError(
881 f"Invalid einsum_path is specified: {len(input_list) - 1} more "
882 "operands has to be contracted.")
883
884 if einsum_call_arg:
885 return (operands, contraction_list)
886
887 # Return the path along with a nice string representation
888 overall_contraction = input_subscripts + "->" + output_subscript
889 header = ("scaling", "current", "remaining")
890
891 # Compute naive cost
892 # This isn't quite right, need to look into exactly how einsum does this
893 inner_product = (
894 sum(len(set(x)) for x in input_subscripts.split(',')) - num_indices
895 ) > 0
896 naive_cost = _flop_count(
897 indices, inner_product, num_inputs, dimension_dict
898 )
899
900 opt_cost = sum(cost_list) + 1
901 speedup = naive_cost / opt_cost
902 max_i = max(size_list)
903
904 path_print = f" Complete contraction: {overall_contraction}\n"
905 path_print += f" Naive scaling: {num_indices}\n"
906 path_print += " Optimized scaling: %d\n" % max(scale_list)
907 path_print += f" Naive FLOP count: {naive_cost:.3e}\n"
908 path_print += f" Optimized FLOP count: {opt_cost:.3e}\n"
909 path_print += f" Theoretical speedup: {speedup:3.3f}\n"
910 path_print += f" Largest intermediate: {max_i:.3e} elements\n"
911 path_print += "-" * 74 + "\n"
912 path_print += "%6s %24s %40s\n" % header
913 path_print += "-" * 74
914
915 for n, contraction in enumerate(contraction_list):
916 _, einsum_str, remaining = contraction
917 remaining_str = ",".join(remaining) + "->" + output_subscript
918 path_run = (scale_list[n], einsum_str, remaining_str)
919 path_print += "\n%4d %24s %40s" % path_run
920
921 path = ['einsum_path'] + path
922 return (path, path_print)
923
924
925def _parse_eq_to_pure_multiplication(a_term, shape_a, b_term, shape_b, out):
926 """If there are no contracted indices, then we can directly transpose and
927 insert singleton dimensions into ``a`` and ``b`` such that (broadcast)
928 elementwise multiplication performs the einsum.
929
930 No need to cache this as it is within the cached
931 ``_parse_eq_to_batch_matmul``.
932
933 """
934 desired_a = ""
935 desired_b = ""
936 new_shape_a = []
937 new_shape_b = []
938 for ix in out:
939 if ix in a_term:
940 desired_a += ix
941 new_shape_a.append(shape_a[a_term.index(ix)])
942 else:
943 new_shape_a.append(1)
944 if ix in b_term:
945 desired_b += ix
946 new_shape_b.append(shape_b[b_term.index(ix)])
947 else:
948 new_shape_b.append(1)
949
950 if desired_a != a_term:
951 eq_a = f"{a_term}->{desired_a}"
952 else:
953 eq_a = None
954 if desired_b != b_term:
955 eq_b = f"{b_term}->{desired_b}"
956 else:
957 eq_b = None
958
959 return (
960 eq_a,
961 eq_b,
962 new_shape_a,
963 new_shape_b,
964 None, # new_shape_ab, not needed since not fusing
965 None, # perm_ab, not needed as we transpose a and b first
966 True, # pure_multiplication=True
967 )
968
969
970@functools.lru_cache(2**12)
971def _parse_eq_to_batch_matmul(eq, shape_a, shape_b):
972 """Cached parsing of a two term einsum equation into the necessary
973 sequence of arguments for contracttion via batched matrix multiplication.
974 The steps we need to specify are:
975
976 1. Remove repeated and trivial indices from the left and right terms,
977 and transpose them, done as a single einsum.
978 2. Fuse the remaining indices so we have two 3D tensors.
979 3. Perform the batched matrix multiplication.
980 4. Unfuse the output to get the desired final index order.
981
982 """
983 lhs, out = eq.split("->")
984 a_term, b_term = lhs.split(",")
985
986 if len(a_term) != len(shape_a):
987 raise ValueError(f"Term '{a_term}' does not match shape {shape_a}.")
988 if len(b_term) != len(shape_b):
989 raise ValueError(f"Term '{b_term}' does not match shape {shape_b}.")
990
991 sizes = {}
992 singletons = set()
993
994 # parse left term to unique indices with size > 1
995 left = {}
996 for ix, d in zip(a_term, shape_a):
997 if d == 1:
998 # everything (including broadcasting) works nicely if simply ignore
999 # such dimensions, but we do need to track if they appear in output
1000 # and thus should be reintroduced later
1001 singletons.add(ix)
1002 continue
1003 if sizes.setdefault(ix, d) != d:
1004 # set and check size
1005 raise ValueError(
1006 f"Index {ix} has mismatched sizes {sizes[ix]} and {d}."
1007 )
1008 left[ix] = True
1009
1010 # parse right term to unique indices with size > 1
1011 right = {}
1012 for ix, d in zip(b_term, shape_b):
1013 # broadcast indices (size 1 on one input and size != 1
1014 # on the other) should not be treated as singletons
1015 if d == 1:
1016 if ix not in left:
1017 singletons.add(ix)
1018 continue
1019 singletons.discard(ix)
1020
1021 if sizes.setdefault(ix, d) != d:
1022 # set and check size
1023 raise ValueError(
1024 f"Index {ix} has mismatched sizes {sizes[ix]} and {d}."
1025 )
1026 right[ix] = True
1027
1028 # now we classify the unique size > 1 indices only
1029 bat_inds = [] # appears on A, B, O
1030 con_inds = [] # appears on A, B, .
1031 a_keep = [] # appears on A, ., O
1032 b_keep = [] # appears on ., B, O
1033 # other indices (appearing on A or B only) will
1034 # be summed or traced out prior to the matmul
1035 for ix in left:
1036 if right.pop(ix, False):
1037 if ix in out:
1038 bat_inds.append(ix)
1039 else:
1040 con_inds.append(ix)
1041 elif ix in out:
1042 a_keep.append(ix)
1043 # now only indices unique to right remain
1044 for ix in right:
1045 if ix in out:
1046 b_keep.append(ix)
1047
1048 if not con_inds:
1049 # contraction is pure multiplication, prepare inputs differently
1050 return _parse_eq_to_pure_multiplication(
1051 a_term, shape_a, b_term, shape_b, out
1052 )
1053
1054 # only need the size one indices that appear in the output
1055 singletons = [ix for ix in out if ix in singletons]
1056
1057 # take diagonal, remove any trivial axes and transpose left
1058 desired_a = "".join((*bat_inds, *a_keep, *con_inds))
1059 if a_term != desired_a:
1060 eq_a = f"{a_term}->{desired_a}"
1061 else:
1062 eq_a = None
1063
1064 # take diagonal, remove any trivial axes and transpose right
1065 desired_b = "".join((*bat_inds, *con_inds, *b_keep))
1066 if b_term != desired_b:
1067 eq_b = f"{b_term}->{desired_b}"
1068 else:
1069 eq_b = None
1070
1071 # then we want to reshape
1072 if bat_inds:
1073 lgroups = (bat_inds, a_keep, con_inds)
1074 rgroups = (bat_inds, con_inds, b_keep)
1075 ogroups = (bat_inds, a_keep, b_keep)
1076 else:
1077 # avoid size 1 batch dimension if no batch indices
1078 lgroups = (a_keep, con_inds)
1079 rgroups = (con_inds, b_keep)
1080 ogroups = (a_keep, b_keep)
1081
1082 if any(len(group) != 1 for group in lgroups):
1083 # need to fuse 'kept' and contracted indices
1084 # (though could allow batch indices to be broadcast)
1085 new_shape_a = tuple(
1086 functools.reduce(operator.mul, (sizes[ix] for ix in ix_group), 1)
1087 for ix_group in lgroups
1088 )
1089 else:
1090 new_shape_a = None
1091
1092 if any(len(group) != 1 for group in rgroups):
1093 # need to fuse 'kept' and contracted indices
1094 # (though could allow batch indices to be broadcast)
1095 new_shape_b = tuple(
1096 functools.reduce(operator.mul, (sizes[ix] for ix in ix_group), 1)
1097 for ix_group in rgroups
1098 )
1099 else:
1100 new_shape_b = None
1101
1102 if any(len(group) != 1 for group in ogroups) or singletons:
1103 new_shape_ab = (1,) * len(singletons) + tuple(
1104 sizes[ix] for ix_group in ogroups for ix in ix_group
1105 )
1106 else:
1107 new_shape_ab = None
1108
1109 # then we might need to permute the matmul produced output:
1110 out_produced = "".join((*singletons, *bat_inds, *a_keep, *b_keep))
1111 if out_produced != out:
1112 perm_ab = tuple(out_produced.index(ix) for ix in out)
1113 else:
1114 perm_ab = None
1115
1116 return (
1117 eq_a,
1118 eq_b,
1119 new_shape_a,
1120 new_shape_b,
1121 new_shape_ab,
1122 perm_ab,
1123 False, # pure_multiplication=False
1124 )
1125
1126
1127@functools.lru_cache(maxsize=64)
1128def _parse_output_order(order, a_is_fcontig, b_is_fcontig):
1129 order = order.upper()
1130 if order == "K":
1131 return None
1132 elif order in "CF":
1133 return order
1134 elif order == "A":
1135 if a_is_fcontig and b_is_fcontig:
1136 return "F"
1137 else:
1138 return "C"
1139 else:
1140 raise ValueError(
1141 "ValueError: order must be one of "
1142 f"'C', 'F', 'A', or 'K' (got '{order}')"
1143 )
1144
1145
1146def bmm_einsum(eq, a, b, out=None, **kwargs):
1147 """Perform arbitrary pairwise einsums using only ``matmul``, or
1148 ``multiply`` if no contracted indices are involved (plus maybe single term
1149 ``einsum`` to prepare the terms individually). The logic for each is cached
1150 based on the equation and array shape, and each step is only performed if
1151 necessary.
1152
1153 Parameters
1154 ----------
1155 eq : str
1156 The einsum equation.
1157 a : array_like
1158 The first array to contract.
1159 b : array_like
1160 The second array to contract.
1161
1162 Returns
1163 -------
1164 array_like
1165
1166 Notes
1167 -----
1168 A fuller description of this algorithm, and original source for this
1169 implementation, can be found at https://github.com/jcmgray/einsum_bmm.
1170 """
1171 (
1172 eq_a,
1173 eq_b,
1174 new_shape_a,
1175 new_shape_b,
1176 new_shape_ab,
1177 perm_ab,
1178 pure_multiplication,
1179 ) = _parse_eq_to_batch_matmul(eq, a.shape, b.shape)
1180
1181 # n.b. one could special case various cases to call c_einsum directly here
1182
1183 # need to handle `order` a little manually, since we do transpose
1184 # operations before and potentially after the ufunc calls
1185 output_order = _parse_output_order(
1186 kwargs.pop("order", "K"), a.flags.f_contiguous, b.flags.f_contiguous
1187 )
1188
1189 # prepare left
1190 if eq_a is not None:
1191 # diagonals, sums, and tranpose
1192 a = c_einsum(eq_a, a)
1193 if new_shape_a is not None:
1194 a = reshape(a, new_shape_a)
1195
1196 # prepare right
1197 if eq_b is not None:
1198 # diagonals, sums, and tranpose
1199 b = c_einsum(eq_b, b)
1200 if new_shape_b is not None:
