Skip to main content

candle_einops/
einsum.rs

1use std::cell::RefCell;
2use std::collections::VecDeque;
3
4use candle_core::{DType, Device, Result, Tensor};
5
6use crate::backend::execute_tensor_permute_and_compose;
7
8/// Validated compile-time plan for the unary explicit-output einsum slice.
9#[doc(hidden)]
10#[derive(Clone, Copy, Debug)]
11pub struct UnaryEinsumSpec<'a> {
12    input_rank: usize,
13    output_rank: usize,
14    permutation: &'a [usize],
15}
16
17impl<'a> UnaryEinsumSpec<'a> {
18    /// Constructs a plan emitted by `candle-einops-macros`.
19    #[doc(hidden)]
20    pub const fn new(input_rank: usize, output_rank: usize, permutation: &'a [usize]) -> Self {
21        Self {
22            input_rank,
23            output_rank,
24            permutation,
25        }
26    }
27}
28
29/// Executes a unary explicit-output einsum plan.
30#[doc(hidden)]
31pub fn execute_unary_einsum<T>(operand: &T, spec: UnaryEinsumSpec<'_>) -> Result<Tensor>
32where
33    T: AsRef<Tensor> + ?Sized,
34{
35    let operand = operand.as_ref();
36    if operand.rank() != spec.input_rank {
37        candle_core::bail!(
38            "einsum operand 0 has rank {}, expected {} for the equation input",
39            operand.rank(),
40            spec.input_rank,
41        )
42    }
43    if spec.output_rank > spec.input_rank {
44        candle_core::bail!(
45            "invalid unary einsum plan: output rank {} exceeds input rank {}",
46            spec.output_rank,
47            spec.input_rank,
48        )
49    }
50    if spec.permutation.len() != spec.input_rank {
51        candle_core::bail!(
52            "invalid unary einsum plan: permutation has {} axes, expected {}",
53            spec.permutation.len(),
54            spec.input_rank,
55        )
56    }
57
58    let mut seen = vec![false; spec.input_rank];
59    for &axis in spec.permutation {
60        if axis >= spec.input_rank {
61            candle_core::bail!(
62                "invalid unary einsum plan: permutation axis {axis} is out of range for rank {}",
63                spec.input_rank,
64            )
65        }
66        if seen[axis] {
67            candle_core::bail!(
68                "invalid unary einsum plan: permutation contains axis {axis} more than once"
69            )
70        }
71        seen[axis] = true;
72    }
73
74    let is_identity = spec.permutation.iter().copied().eq(0..spec.input_rank);
75    let mut output = if is_identity {
76        operand.clone()
77    } else {
78        operand
79            .permute(spec.permutation)
80            .map_err(|error| error.context("einsum unary permutation"))?
81    };
82
83    if spec.output_rank < spec.input_rank {
84        let reduction_axes = (spec.output_rank..spec.input_rank).collect::<Vec<_>>();
85        output = output
86            .sum(reduction_axes.as_slice())
87            .map_err(|error| error.context("einsum unary reduction"))?;
88    }
89
90    Ok(output)
91}
92
93/// Validated compile-time plan for a binary explicit-output einsum.
94#[doc(hidden)]
95#[derive(Clone, Copy, Debug)]
96pub struct BinaryEinsumSpec<'a> {
97    input_ranks: [usize; 2],
98    reduction_axes: [&'a [usize]; 2],
99    permutations: [&'a [usize]; 2],
100    batch_rank: usize,
101    left_free_rank: usize,
102    contracted_rank: usize,
103    right_free_rank: usize,
104    batch_labels: &'a [&'a str],
105    contracted_labels: &'a [&'a str],
106    output_permutation: &'a [usize],
107}
108
109/// One compile-time axis-list pattern containing at most one runtime ellipsis.
110#[doc(hidden)]
111#[derive(Clone, Copy, Debug)]
112pub struct EinsumAxisPattern<'a> {
113    labels: &'a [&'a str],
114    ellipsis_position: Option<usize>,
115}
116
117impl<'a> EinsumAxisPattern<'a> {
118    /// Constructs an axis pattern emitted by `candle-einops-macros`.
119    #[doc(hidden)]
120    pub const fn new(labels: &'a [&'a str], ellipsis_position: Option<usize>) -> Self {
121        Self {
122            labels,
123            ellipsis_position,
124        }
125    }
126}
127
128/// Runtime-normalized plan for an equation containing ellipses, repeated
129/// labels, or more than two operands.
130#[doc(hidden)]
131#[derive(Clone, Copy, Debug)]
132pub struct EllipsisEinsumSpec<'a> {
133    operands: &'a [EinsumAxisPattern<'a>],
134    output: EinsumAxisPattern<'a>,
135}
136
137impl<'a> EllipsisEinsumSpec<'a> {
138    /// Constructs an ellipsis plan emitted by `candle-einops-macros`.
139    #[doc(hidden)]
140    pub const fn new(operands: &'a [EinsumAxisPattern<'a>], output: EinsumAxisPattern<'a>) -> Self {
141        Self { operands, output }
142    }
143}
144
145impl<'a> BinaryEinsumSpec<'a> {
146    /// Constructs a plan emitted by `candle-einops-macros`.
147    #[doc(hidden)]
148    #[allow(clippy::too_many_arguments)]
149    pub const fn new(
150        input_ranks: [usize; 2],
151        reduction_axes: [&'a [usize]; 2],
152        permutations: [&'a [usize]; 2],
153        batch_rank: usize,
154        left_free_rank: usize,
155        contracted_rank: usize,
156        right_free_rank: usize,
157        batch_labels: &'a [&'a str],
158        contracted_labels: &'a [&'a str],
159        output_permutation: &'a [usize],
160    ) -> Self {
161        Self {
162            input_ranks,
163            reduction_axes,
164            permutations,
165            batch_rank,
166            left_free_rank,
167            contracted_rank,
168            right_free_rank,
169            batch_labels,
170            contracted_labels,
171            output_permutation,
172        }
173    }
174}
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177enum BinaryExecution {
178    Multiply,
179    CanonicalMatmul,
180    General,
181}
182
183/// Executes a binary GEMM-lowered explicit-output einsum plan.
184#[doc(hidden)]
185pub fn execute_binary_einsum<L, R>(
186    left: &L,
187    right: &R,
188    spec: BinaryEinsumSpec<'_>,
189) -> Result<Tensor>
190where
191    L: AsRef<Tensor> + ?Sized,
192    R: AsRef<Tensor> + ?Sized,
193{
194    execute_binary_with(left, right, spec, BinaryExecution::General)
195}
196
197/// Executes a binary equation with no contracted labels as broadcast multiplication.
198#[doc(hidden)]
199pub fn execute_binary_multiply<L, R>(
200    left: &L,
201    right: &R,
202    spec: BinaryEinsumSpec<'_>,
203) -> Result<Tensor>
204where
205    L: AsRef<Tensor> + ?Sized,
206    R: AsRef<Tensor> + ?Sized,
207{
208    execute_binary_with(left, right, spec, BinaryExecution::Multiply)
209}
210
211/// Executes a canonical rank-two or rank-three contraction with direct matmul.
212#[doc(hidden)]
213pub fn execute_canonical_binary_einsum<L, R>(
214    left: &L,
215    right: &R,
216    spec: BinaryEinsumSpec<'_>,
217) -> Result<Tensor>
218where
219    L: AsRef<Tensor> + ?Sized,
220    R: AsRef<Tensor> + ?Sized,
221{
222    execute_binary_with(left, right, spec, BinaryExecution::CanonicalMatmul)
223}
224
225fn execute_binary_with<L, R>(
226    left: &L,
227    right: &R,
228    spec: BinaryEinsumSpec<'_>,
229    requested_execution: BinaryExecution,
230) -> Result<Tensor>
231where
232    L: AsRef<Tensor> + ?Sized,
233    R: AsRef<Tensor> + ?Sized,
234{
235    let left = left.as_ref();
236    let right = right.as_ref();
237    for (index, (operand, expected_rank)) in
238        [left, right].into_iter().zip(spec.input_ranks).enumerate()
239    {
240        if operand.rank() != expected_rank {
241            candle_core::bail!(
242                "einsum operand {index} has rank {}, expected {expected_rank} for the equation input",
243                operand.rank(),
244            )
245        }
246    }
247    if left.dtype() != right.dtype() {
248        candle_core::bail!(
249            "einsum operands have different dtypes: left {:?}, right {:?}",
250            left.dtype(),
251            right.dtype(),
252        )
253    }
254    if !left.device().same_device(right.device()) {
255        candle_core::bail!(
256            "einsum operands are on different devices: left {:?}, right {:?}",
257            left.device(),
258            right.device(),
259        )
260    }
261
262    let left = prepare_operand(
263        left,
264        0,
265        spec.input_ranks[0],
266        spec.reduction_axes[0],
267        spec.permutations[0],
268    )?;
269    let right = prepare_operand(
270        right,
271        1,
272        spec.input_ranks[1],
273        spec.reduction_axes[1],
274        spec.permutations[1],
275    )?;
276
277    let left_expected_rank = spec
278        .batch_rank
279        .checked_add(spec.left_free_rank)
280        .and_then(|rank| rank.checked_add(spec.contracted_rank))
281        .ok_or_else(|| {
282            candle_core::Error::msg("einsum binary canonical left rank overflows usize")
283        })?;
284    let right_expected_rank = spec
285        .batch_rank
286        .checked_add(spec.contracted_rank)
287        .and_then(|rank| rank.checked_add(spec.right_free_rank))
288        .ok_or_else(|| {
289            candle_core::Error::msg("einsum binary canonical right rank overflows usize")
290        })?;
291    if left.rank() != left_expected_rank || right.rank() != right_expected_rank {
292        candle_core::bail!(
293            "invalid binary einsum plan: canonical ranks are left {}, right {}, expected left {left_expected_rank}, right {right_expected_rank}",
294            left.rank(),
295            right.rank(),
296        )
297    }
298    if spec.batch_labels.len() != spec.batch_rank
299        || spec.contracted_labels.len() != spec.contracted_rank
300    {
301        candle_core::bail!(
302            "invalid binary einsum plan: shared-label metadata does not match canonical ranks"
303        )
304    }
305
306    let left_dims = left.dims().to_vec();
307    let right_dims = right.dims().to_vec();
308    let mut batch_dims = Vec::with_capacity(spec.batch_rank);
309    for axis in 0..spec.batch_rank {
310        batch_dims.push(resolve_extent(
311            spec.batch_labels[axis],
312            left_dims[axis],
313            right_dims[axis],
314        )?);
315    }
316    let left_free_start = spec.batch_rank;
317    let contracted_left_start = left_free_start + spec.left_free_rank;
318    let contracted_right_start = spec.batch_rank;
319    let mut contracted_dims = Vec::with_capacity(spec.contracted_rank);
320    for axis in 0..spec.contracted_rank {
321        contracted_dims.push(resolve_extent(
322            spec.contracted_labels[axis],
323            left_dims[contracted_left_start + axis],
324            right_dims[contracted_right_start + axis],
325        )?);
326    }
327    let left_free_dims = &left_dims[left_free_start..contracted_left_start];
328    let right_free_start = contracted_right_start + spec.contracted_rank;
329    let right_free_dims = &right_dims[right_free_start..];
330
331    let mut left_shape = batch_dims.clone();
332    left_shape.extend_from_slice(left_free_dims);
333    left_shape.extend_from_slice(&contracted_dims);
334    let mut right_shape = batch_dims.clone();
335    right_shape.extend_from_slice(&contracted_dims);
336    right_shape.extend_from_slice(right_free_dims);
337
338    let b = checked_product(&batch_dims, "batch (B)")?;
339    let m = checked_product(left_free_dims, "left-free (M)")?;
340    let k = checked_product(&contracted_dims, "contracted (K)")?;
341    let n = checked_product(right_free_dims, "right-free (N)")?;
342
343    let mut canonical_output_shape = batch_dims.clone();
344    canonical_output_shape.extend_from_slice(left_free_dims);
345    canonical_output_shape.extend_from_slice(right_free_dims);
346    validate_permutation(
347        spec.output_permutation,
348        canonical_output_shape.len(),
349        "output",
350    )?;
351
352    let execution = if spec.contracted_rank == 0 {
353        BinaryExecution::Multiply
354    } else {
355        requested_execution
356    };
357
358    if execution == BinaryExecution::CanonicalMatmul {
359        let canonical = spec.left_free_rank == 1
360            && spec.contracted_rank == 1
361            && spec.right_free_rank == 1
362            && spec.reduction_axes.iter().all(|axes| axes.is_empty())
363            && spec
364                .permutations
365                .iter()
366                .all(|permutation| permutation.iter().copied().eq(0..permutation.len()));
367        if !canonical {
368            candle_core::bail!("invalid binary einsum plan: direct matmul path is not canonical")
369        }
370    }
371
372    if execution != BinaryExecution::Multiply && (b == 0 || m == 0 || k == 0 || n == 0) {
373        let output = graph_preserving_zero(&left, &right, &canonical_output_shape)?;
374        return apply_output_permutation(output, spec.output_permutation);
375    }
376
377    if execution == BinaryExecution::Multiply {
378        if spec.contracted_rank != 0 {
379            candle_core::bail!(
380                "invalid binary einsum plan: multiply fast path received contracted axes"
381            )
382        }
383        let mut left = left;
384        for _ in 0..spec.right_free_rank {
385            left = left.unsqueeze(left.rank()).map_err(|error| {
386                error.context("einsum binary multiply left free-axis alignment")
387            })?;
388        }
389        let mut right = right;
390        for _ in 0..spec.left_free_rank {
391            right = right.unsqueeze(spec.batch_rank).map_err(|error| {
392                error.context("einsum binary multiply right free-axis alignment")
393            })?;
394        }
395        let output = left
396            .broadcast_mul(&right)
397            .map_err(|error| error.context("einsum binary broadcast multiplication"))?;
398        return apply_output_permutation(output, spec.output_permutation);
399    }
400
401    if execution == BinaryExecution::CanonicalMatmul {
402        let left =
403            materialize_broadcast_if_needed(&left, &left_shape, "einsum binary left broadcast")?;
404        let right =
405            materialize_broadcast_if_needed(&right, &right_shape, "einsum binary right broadcast")?;
406        let output = left
407            .matmul(&right)
408            .map_err(|error| error.context("einsum binary B/M/K/N matmul"))?;
409        return apply_output_permutation(output, spec.output_permutation);
410    }
411
412    let left = left
413        .broadcast_as(left_shape)
414        .map_err(|error| error.context("einsum binary left broadcast"))?;
415    let left = pack_canonical_operand(
416        &left,
417        &[b, m, k],
418        &[spec.batch_rank, spec.left_free_rank, spec.contracted_rank],
419        "einsum binary left B/M/K reshape",
420    )?;
421    let right = right
422        .broadcast_as(right_shape)
423        .map_err(|error| error.context("einsum binary right broadcast"))?;
424    let right = pack_canonical_operand(
425        &right,
426        &[b, k, n],
427        &[spec.batch_rank, spec.contracted_rank, spec.right_free_rank],
428        "einsum binary right B/K/N reshape",
429    )?;
430    let output = left
431        .matmul(&right)
432        .map_err(|error| error.context("einsum binary B/M/K/N matmul"))?;
433    let output = output
434        .reshape(canonical_output_shape)
435        .map_err(|error| error.context("einsum binary canonical output reshape"))?;
436    apply_output_permutation(output, spec.output_permutation)
437}
438
439fn graph_preserving_zero(left: &Tensor, right: &Tensor, shape: &[usize]) -> Result<Tensor> {
440    let left = left
441        .flatten_all()
442        .map_err(|error| error.context("einsum binary left zero flatten"))?;
443    let right = right
444        .flatten_all()
445        .map_err(|error| error.context("einsum binary right zero flatten"))?;
446    Tensor::cat(&[&left, &right], 0)
447        .and_then(|operands| operands.sum_all())
448        .map_err(|error| error.context("einsum binary zero operand anchor"))?
449        .broadcast_as(shape)
450        .map_err(|error| error.context("einsum binary zero output broadcast"))
451}
452
453/// Expands and executes a unary equation containing an ellipsis.
454#[doc(hidden)]
455pub fn execute_unary_ellipsis_einsum<T>(operand: &T, spec: EllipsisEinsumSpec<'_>) -> Result<Tensor>
456where
457    T: AsRef<Tensor> + ?Sized,
458{
459    if spec.operands.len() != 1 {
460        candle_core::bail!(
461            "invalid ellipsis einsum plan: unary execution received {} operand patterns",
462            spec.operands.len()
463        )
464    }
465    let operand = operand.as_ref();
466    let capture = ellipsis_capture(operand, 0, spec.operands[0])?;
467    let normalized = normalize_ellipsis_operand(operand, spec.operands[0], capture, capture, 0)?;
468    let input_axes = expand_axis_pattern(spec.operands[0], capture, true);
469    let (normalized, input_axes) = normalize_repeated_axes(normalized, input_axes, 0)?;
470    let output_axes = expand_axis_pattern(spec.output, capture, false);
471    validate_expanded_output(&[&input_axes], &output_axes)?;
472    let permutation = output_axes
473        .iter()
474        .chain(input_axes.iter().filter(|axis| !output_axes.contains(axis)))
475        .map(|axis| {
476            input_axes
477                .iter()
478                .position(|candidate| candidate == axis)
479                .expect("validated unary ellipsis axis")
480        })
481        .collect::<Vec<_>>();
482    execute_unary_einsum(
483        &normalized,
484        UnaryEinsumSpec::new(input_axes.len(), output_axes.len(), &permutation),
485    )
486}
487
488/// Expands and executes a binary equation containing an ellipsis.
489#[doc(hidden)]
490pub fn execute_binary_ellipsis_einsum<L, R>(
491    left: &L,
492    right: &R,
493    spec: EllipsisEinsumSpec<'_>,
494) -> Result<Tensor>
495where
496    L: AsRef<Tensor> + ?Sized,
497    R: AsRef<Tensor> + ?Sized,
498{
499    if spec.operands.len() != 2 {
500        candle_core::bail!(
501            "invalid ellipsis einsum plan: binary execution received {} operand patterns",
502            spec.operands.len()
503        )
504    }
505    let left = left.as_ref();
506    let right = right.as_ref();
507    let captures = [
508        ellipsis_capture(left, 0, spec.operands[0])?,
509        ellipsis_capture(right, 1, spec.operands[1])?,
510    ];
511    let maximum_capture = captures[0].max(captures[1]);
512    let left = normalize_ellipsis_operand(left, spec.operands[0], captures[0], maximum_capture, 0)?;
513    let right =
514        normalize_ellipsis_operand(right, spec.operands[1], captures[1], maximum_capture, 1)?;
515    let left_axes = expand_axis_pattern(spec.operands[0], maximum_capture, true);
516    let right_axes = expand_axis_pattern(spec.operands[1], maximum_capture, true);
517    let (left, left_axes) = normalize_repeated_axes(left, left_axes, 0)?;
518    let (right, right_axes) = normalize_repeated_axes(right, right_axes, 1)?;
519    let output_axes = expand_axis_pattern(spec.output, maximum_capture, false);
520    validate_expanded_output(&[&left_axes, &right_axes], &output_axes)?;
521    execute_expanded_binary(&left, &right, &left_axes, &right_axes, &output_axes)
522}
523
524/// Converts one generated operand binding to the tensor reference used by the
525/// arbitrary-arity runtime ABI.
526#[doc(hidden)]
527pub fn einsum_operand_ref<T>(operand: &T) -> &Tensor
528where
529    T: AsRef<Tensor> + ?Sized,
530{
531    operand.as_ref()
532}
533
534/// Normalizes and greedily contracts an arbitrary number of operands.
535#[doc(hidden)]
536pub fn execute_nary_einsum(operands: &[&Tensor], spec: EllipsisEinsumSpec<'_>) -> Result<Tensor> {
537    execute_nary_einsum_internal(operands, spec, NaryExecutionStrategy::Selected)
538        .map(|(tensor, _)| tensor)
539}
540
541#[derive(Clone, Copy, Debug, Eq, PartialEq)]
542enum NaryExecutionStrategy {
543    Selected,
544    #[cfg(test)]
545    StreamingGreedy,
546}
547
548#[derive(Clone, Debug, Default, Eq, PartialEq)]
549struct NaryExecutionTrace {
550    used_exact: bool,
551    used_cached_greedy: bool,
552    member_sequence: Vec<(u64, u64)>,
553    final_permutations: usize,
554    intermediates: Vec<NaryIntermediateTrace>,
555}
556
557#[derive(Clone, Debug, Eq, PartialEq)]
558struct NaryIntermediateTrace {
559    canonical: bool,
560    estimated: NaryPairCost,
561    execution_graph: NaryPairCost,
562    output_layout: NaryLayoutEstimate,
563}
564
565fn prepare_nary_einsum<'a>(
566    operands: &[&Tensor],
567    spec: EllipsisEinsumSpec<'a>,
568) -> Result<(Vec<PlannedOperand<'a>>, Vec<ExpandedAxis<'a>>)> {
569    if operands.is_empty() {
570        candle_core::bail!("invalid n-ary einsum plan: at least one operand is required")
571    }
572    if spec.operands.len() != operands.len() {
573        candle_core::bail!(
574            "invalid n-ary einsum plan: received {} tensors but {} operand patterns",
575            operands.len(),
576            spec.operands.len()
577        )
578    }
579    let first = operands[0];
580    for (index, operand) in operands.iter().copied().enumerate().skip(1) {
581        if operand.dtype() != first.dtype() {
582            candle_core::bail!(
583                "einsum operands have different dtypes: operand 0 {:?}, operand {index} {:?}",
584                first.dtype(),
585                operand.dtype()
586            )
587        }
588        if !operand.device().same_device(first.device()) {
589            candle_core::bail!(
590                "einsum operands are on different devices: operand 0 {:?}, operand {index} {:?}",
591                first.device(),
592                operand.device()
593            )
594        }
595    }
596    let captures = operands
597        .iter()
598        .zip(spec.operands)
599        .enumerate()
600        .map(|(index, (operand, pattern))| ellipsis_capture(operand, index, *pattern))
601        .collect::<Result<Vec<_>>>()?;
602    let maximum_capture = captures.iter().copied().max().unwrap_or(0);
603    let mut planned = Vec::with_capacity(operands.len());
604    for (index, ((operand, pattern), capture)) in
605        operands.iter().zip(spec.operands).zip(captures).enumerate()
606    {
607        let normalized =
608            normalize_ellipsis_operand(operand, *pattern, capture, maximum_capture, index)?;
609        let axes = expand_axis_pattern(*pattern, maximum_capture, true);
610        let (tensor, axes) = normalize_repeated_axes(normalized, axes, index)?;
611        planned.push(PlannedOperand {
612            tensor,
613            axes,
614            stable_ordinal: index,
615            members: 1_u64.checked_shl(index as u32).unwrap_or(0),
616        });
617    }
618    let output_axes = expand_axis_pattern(spec.output, maximum_capture, false);
619    let input_axes = planned
620        .iter()
621        .map(|operand| operand.axes.as_slice())
622        .collect::<Vec<_>>();
623    validate_expanded_output(&input_axes, &output_axes)?;
624    validate_nary_broadcasts(&planned)?;
625    Ok((planned, output_axes))
626}
627
628fn select_prepared_nary_plan<'a>(
629    planned: &[PlannedOperand<'a>],
630    output_axes: &[ExpandedAxis<'a>],
631) -> NaryPlannerDecision<'a> {
632    let first = &planned[0].tensor;
633    let metadata = prepared_nary_metadata(planned);
634    select_layout_aware_plan(
635        &metadata,
636        output_axes,
637        first.dtype(),
638        first.device().is_cpu(),
639    )
640}
641
642fn prepared_nary_metadata<'a>(planned: &[PlannedOperand<'a>]) -> Vec<NaryPlannerMetadata<'a>> {
643    planned
644        .iter()
645        .map(|operand| NaryPlannerMetadata {
646            stable_ordinal: operand.stable_ordinal,
647            axes: operand
648                .axes
649                .iter()
650                .copied()
651                .zip(operand.tensor.dims().iter().copied())
652                .collect(),
653            layout: if operand.tensor.is_contiguous() {
654                NaryLayoutEstimate::Contiguous
655            } else {
656                NaryLayoutEstimate::Strided(operand.tensor.stride().to_vec())
657            },
658            members: operand.members,
659        })
660        .collect()
661}
662
663/// Runs production n-ary preparation and selection without executing a plan.
664#[cfg(feature = "benchmark-internals")]
665#[doc(hidden)]
666pub fn benchmark_nary_planner_selects_exact(
667    operands: &[&Tensor],
668    spec: EllipsisEinsumSpec<'_>,
669) -> Result<bool> {
670    let (planned, output_axes) = prepare_nary_einsum(operands, spec)?;
671    Ok(matches!(
672        select_prepared_nary_plan(&planned, &output_axes),
673        NaryPlannerDecision::Exact(_)
674    ))
675}
676
677/// Public-operation estimates from the production binary lowering classifier.
678#[cfg(feature = "benchmark-internals")]
679#[doc(hidden)]
680#[derive(Clone, Debug, Eq, PartialEq)]
681pub struct BenchmarkBinaryGraphEstimate {
682    pub work: u128,
683    pub output_elements: u128,
684    pub copy_bytes: u128,
685    pub submissions: u128,
686}
687
688/// Runs the production lowering classifier without executing tensor operations.
689#[cfg(feature = "benchmark-internals")]
690#[doc(hidden)]
691pub fn benchmark_binary_graph_estimate(
692    left_labels: &[&str],
693    left_dims: &[usize],
694    left_strides: &[usize],
695    right_labels: &[&str],
696    right_dims: &[usize],
697    right_strides: &[usize],
698    output_labels: &[&str],
699) -> Result<BenchmarkBinaryGraphEstimate> {
700    let left_axes = left_labels
701        .iter()
702        .copied()
703        .map(ExpandedAxis::Named)
704        .collect::<Vec<_>>();
705    let right_axes = right_labels
706        .iter()
707        .copied()
708        .map(ExpandedAxis::Named)
709        .collect::<Vec<_>>();
710    let output_axes = output_labels
711        .iter()
712        .copied()
713        .map(ExpandedAxis::Named)
714        .collect::<Vec<_>>();
715    let graph = classify_expanded_binary_graph(
716        BinaryGraphOperand {
717            axes: &left_axes,
718            dims: left_dims,
719            layout: BinaryOperandLayout::Strided(left_strides),
720        },
721        BinaryGraphOperand {
722            axes: &right_axes,
723            dims: right_dims,
724            layout: BinaryOperandLayout::Strided(right_strides),
725        },
726        &output_axes,
727        4,
728    )?;
729    Ok(BenchmarkBinaryGraphEstimate {
730        work: graph.work,
731        output_elements: graph.output_elements,
732        copy_bytes: graph.copy_bytes,
733        submissions: graph.submissions,
734    })
735}
736
737fn execute_nary_einsum_internal<'a>(
738    operands: &[&Tensor],
739    spec: EllipsisEinsumSpec<'a>,
740    strategy: NaryExecutionStrategy,
741) -> Result<(Tensor, NaryExecutionTrace)> {
742    let (mut planned, output_axes) = prepare_nary_einsum(operands, spec)?;
743    let global_axis_order = stable_axis_order(&planned);
744
745    let decision = if strategy == NaryExecutionStrategy::Selected {
746        select_prepared_nary_plan(&planned, &output_axes)
747    } else {
748        NaryPlannerDecision::Greedy(NaryGreedyReason::Arity)
749    };
750    let calibrated_greedy = matches!(
751        &decision,
752        NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration)
753    );
754    let greedy_cache_key = calibrated_greedy
755        .then(|| nary_plan_cache_key(&prepared_nary_metadata(&planned), &output_axes));
756    let cached_greedy = greedy_cache_key.as_ref().and_then(cached_nary_sequence);
757    let mut trace = NaryExecutionTrace::default();
758    match decision {
759        NaryPlannerDecision::Exact(plan) => {
760            trace.used_exact = true;
761            for step in plan.steps {
762                let left_index = planned
763                    .iter()
764                    .position(|operand| operand.members == step.members.0)
765                    .expect("exact plan left member set remains live");
766                let right_index = planned
767                    .iter()
768                    .position(|operand| operand.members == step.members.1)
769                    .expect("exact plan right member set remains live");
770                debug_assert!(left_index < right_index);
771                let right = planned.remove(right_index);
772                let left = planned.remove(left_index);
773                let graph = classify_expanded_binary_graph(
774                    BinaryGraphOperand {
775                        axes: &left.axes,
776                        dims: left.tensor.dims(),
777                        layout: BinaryOperandLayout::Strided(left.tensor.stride()),
778                    },
779                    BinaryGraphOperand {
780                        axes: &right.axes,
781                        dims: right.tensor.dims(),
782                        layout: BinaryOperandLayout::Strided(right.tensor.stride()),
783                    },
784                    &step.output_axes,
785                    left.tensor.dtype().size_in_bytes(),
786                )?;
787                let execution_graph = graph.cost();
788                debug_assert_eq!(step.estimate, execution_graph);
789                let (tensor, axes) = execute_expanded_binary_canonical(
790                    &left.tensor,
791                    &right.tensor,
792                    &left.axes,
793                    &right.axes,
794                    &step.output_axes,
795                )?;
796                debug_assert_eq!(axes, step.output_axes);
797                planned.insert(
798                    left_index,
799                    PlannedOperand {
800                        tensor,
801                        axes,
802                        stable_ordinal: left.stable_ordinal.min(right.stable_ordinal),
803                        members: left.members | right.members,
804                    },
805                );
806                trace.member_sequence.push(step.members);
807                trace.intermediates.push(NaryIntermediateTrace {
808                    canonical: true,
809                    estimated: step.estimate,
810                    execution_graph,
811                    output_layout: graph.output_layout,
812                });
813            }
814        }
815        NaryPlannerDecision::Greedy(_) => {
816            trace.used_cached_greedy = cached_greedy.is_some();
817            let mut cached_steps = cached_greedy.as_deref().unwrap_or_default().iter();
818            while planned.len() > 1 {
819                let selected = if let Some(&(left_members, right_members)) = cached_steps.next() {
820                    let left = planned
821                        .iter()
822                        .position(|operand| operand.members == left_members)
823                        .ok_or_else(|| {
824                            candle_core::Error::msg("cached greedy left members are not live")
825                        })?;
826                    let right = planned
827                        .iter()
828                        .position(|operand| operand.members == right_members)
829                        .ok_or_else(|| {
830                            candle_core::Error::msg("cached greedy right members are not live")
831                        })?;
832                    estimate_pair_with_order(
833                        &planned,
834                        left,
835                        right,
836                        &output_axes,
837                        &global_axis_order,
838                    )?
839                } else {
840                    select_nary_pair_with_order(&planned, &output_axes, &global_axis_order)?
841                };
842                let right = planned.remove(selected.right);
843                let left = planned.remove(selected.left);
844                let tensor = execute_expanded_binary(
845                    &left.tensor,
846                    &right.tensor,
847                    &left.axes,
848                    &right.axes,
849                    &selected.output_axes,
850                )?;
851                trace.member_sequence.push((left.members, right.members));
852                planned.insert(
853                    selected.left,
854                    PlannedOperand {
855                        tensor,
856                        axes: selected.output_axes,
857                        stable_ordinal: left.stable_ordinal.min(right.stable_ordinal),
858                        members: left.members | right.members,
859                    },
860                );
861            }
862            if let Some(key) = greedy_cache_key
863                && !trace.used_cached_greedy
864            {
865                cache_nary_sequence(key, trace.member_sequence.clone());
866            }
867        }
868    }
869
870    let final_operand = planned
871        .pop()
872        .expect("non-empty n-ary plan retains one operand");
873    let permutation = output_axes
874        .iter()
875        .chain(
876            final_operand
877                .axes
878                .iter()
879                .filter(|axis| !output_axes.contains(axis)),
880        )
881        .map(|axis| {
882            final_operand
883                .axes
884                .iter()
885                .position(|candidate| candidate == axis)
886                .expect("validated final n-ary output axis")
887        })
888        .collect::<Vec<_>>();
889    let tensor = execute_unary_einsum(
890        &final_operand.tensor,
891        UnaryEinsumSpec::new(final_operand.axes.len(), output_axes.len(), &permutation),
892    )?;
893    trace.final_permutations = 1;
894    Ok((tensor, trace))
895}
896
897#[cfg(test)]
898fn execute_nary_einsum_for_test<'a>(
899    operands: &[&Tensor],
900    spec: EllipsisEinsumSpec<'a>,
901    strategy: NaryExecutionStrategy,
902) -> Result<(Tensor, NaryExecutionTrace)> {
903    execute_nary_einsum_internal(operands, spec, strategy)
904}
905
906fn execute_expanded_binary(
907    left: &Tensor,
908    right: &Tensor,
909    left_axes: &[ExpandedAxis<'_>],
910    right_axes: &[ExpandedAxis<'_>],
911    output_axes: &[ExpandedAxis<'_>],
912) -> Result<Tensor> {
913    let graph = classify_expanded_binary_graph(
914        BinaryGraphOperand {
915            axes: left_axes,
916            dims: left.dims(),
917            layout: BinaryOperandLayout::Strided(left.stride()),
918        },
919        BinaryGraphOperand {
920            axes: right_axes,
921            dims: right.dims(),
922            layout: BinaryOperandLayout::Strided(right.stride()),
923        },
924        output_axes,
925        left.dtype().size_in_bytes(),
926    )?;
927    let plan = graph.plan;
928    let batch_label_storage = plan
929        .batch
930        .iter()
931        .map(ExpandedAxis::display_name)
932        .collect::<Vec<_>>();
933    let contracted_label_storage = plan
934        .contracted
935        .iter()
936        .map(ExpandedAxis::display_name)
937        .collect::<Vec<_>>();
938    let batch_labels = batch_label_storage
939        .iter()
940        .map(String::as_str)
941        .collect::<Vec<_>>();
942    let contracted_labels = contracted_label_storage
943        .iter()
944        .map(String::as_str)
945        .collect::<Vec<_>>();
946    execute_binary_einsum(
947        left,
948        right,
949        BinaryEinsumSpec::new(
950            [left_axes.len(), right_axes.len()],
951            [&plan.left_reductions, &plan.right_reductions],
952            [&plan.left_permutation, &plan.right_permutation],
953            plan.batch.len(),
954            plan.left_free.len(),
955            plan.contracted.len(),
956            plan.right_free.len(),
957            &batch_labels,
958            &contracted_labels,
959            &plan.output_permutation,
960        ),
961    )
962}
963
964fn execute_expanded_binary_canonical<'a>(
965    left: &Tensor,
966    right: &Tensor,
967    left_axes: &[ExpandedAxis<'a>],
968    right_axes: &[ExpandedAxis<'a>],
969    retained_axes: &[ExpandedAxis<'a>],
970) -> Result<(Tensor, Vec<ExpandedAxis<'a>>)> {
971    let plan = classify_expanded_binary(left_axes, right_axes, retained_axes);
972    let canonical = plan
973        .batch
974        .iter()
975        .chain(&plan.left_free)
976        .chain(&plan.right_free)
977        .copied()
978        .collect::<Vec<_>>();
979    let tensor = execute_expanded_binary(left, right, left_axes, right_axes, &canonical)?;
980    Ok((tensor, canonical))
981}
982
983struct PlannedOperand<'a> {
984    tensor: Tensor,
985    axes: Vec<ExpandedAxis<'a>>,
986    stable_ordinal: usize,
987    members: u64,
988}
989
990impl<'a> PlannedOperand<'a> {
991    #[cfg(test)]
992    fn new_for_test(
993        shape: (usize, usize),
994        labels: &[&'a str],
995        stable_ordinal: usize,
996    ) -> Result<Self> {
997        Ok(Self {
998            tensor: Tensor::zeros(shape, candle_core::DType::F32, &candle_core::Device::Cpu)?,
999            axes: labels.iter().copied().map(ExpandedAxis::Named).collect(),
1000            stable_ordinal,
1001            members: 1_u64 << stable_ordinal,
1002        })
1003    }
1004}
1005
1006#[derive(Clone, Debug, Eq, PartialEq)]
1007enum NaryLayoutEstimate {
1008    Contiguous,
1009    Strided(Vec<usize>),
1010    Unsupported,
1011}
1012
1013impl NaryLayoutEstimate {
1014    fn as_binary(&self) -> BinaryOperandLayout<'_> {
1015        match self {
1016            Self::Contiguous => BinaryOperandLayout::Contiguous,
1017            Self::Strided(strides) => BinaryOperandLayout::Strided(strides),
1018            Self::Unsupported => BinaryOperandLayout::Unsupported,
1019        }
1020    }
1021}
1022
1023#[derive(Clone, Debug, Eq, PartialEq)]
1024struct NaryPlannerMetadata<'a> {
1025    stable_ordinal: usize,
1026    axes: Vec<(ExpandedAxis<'a>, usize)>,
1027    layout: NaryLayoutEstimate,
1028    members: u64,
1029}
1030
1031impl<'a> NaryPlannerMetadata<'a> {
1032    #[cfg(test)]
1033    fn new_for_test(
1034        stable_ordinal: usize,
1035        axes: &[(&'a str, usize)],
1036        layout: NaryLayoutEstimate,
1037    ) -> Self {
1038        Self {
1039            stable_ordinal,
1040            axes: axes
1041                .iter()
1042                .map(|&(axis, extent)| (ExpandedAxis::Named(axis), extent))
1043                .collect(),
1044            layout,
1045            members: 1_u64.checked_shl(stable_ordinal as u32).unwrap_or(0),
1046        }
1047    }
1048
1049    fn elements(&self) -> Result<u128> {
1050        checked_nary_product(
1051            &self
1052                .axes
1053                .iter()
1054                .map(|(_, extent)| *extent)
1055                .collect::<Vec<_>>(),
1056        )
1057    }
1058}
1059
1060#[derive(Clone, Debug, Eq, PartialEq)]
1061struct NaryPairCost {
1062    flops: u128,
1063    output_elements: u128,
1064    copy_bytes: u128,
1065    submissions: u128,
1066}
1067
1068#[derive(Clone, Debug, Eq, PartialEq)]
1069struct NaryPlanStep<'a> {
1070    members: (u64, u64),
1071    output_axes: Vec<ExpandedAxis<'a>>,
1072    estimate: NaryPairCost,
1073}
1074
1075#[derive(Clone, Debug, Eq, PartialEq)]
1076struct NaryPlanMetrics {
1077    flops: u128,
1078    intermediate_elements: u128,
1079    output_elements: u128,
1080    copy_bytes: u128,
1081    peak_live_elements: u128,
1082    submissions: u128,
1083    score: u128,
1084}
1085
1086#[derive(Clone, Debug, Eq, PartialEq)]
1087struct NaryContractionPlan<'a> {
1088    steps: Vec<NaryPlanStep<'a>>,
1089    metrics: NaryPlanMetrics,
1090}
1091
1092#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1093enum NaryGreedyReason {
1094    Arity,
1095    DType,
1096    Backend,
1097    UnsupportedLayout,
1098    BelowFlopThreshold,
1099    Calibration,
1100    ModelFailure,
1101}
1102
1103// The bounded exact model remains available for analysis, but the frozen CPU
1104// matrix has not demonstrated an end-to-end execution win over streaming
1105// greedy. Re-enable only with repeated-process evidence, not model score alone.
1106const EXACT_NARY_EXECUTION_CALIBRATED: bool = false;
1107
1108#[derive(Clone, Debug, Eq, PartialEq)]
1109enum NaryPlannerDecision<'a> {
1110    Greedy(NaryGreedyReason),
1111    Exact(NaryContractionPlan<'a>),
1112}
1113
1114#[derive(Clone, Debug, Eq, PartialEq)]
1115struct NaryPlanCacheOperand {
1116    axes: Vec<String>,
1117    dims: Vec<usize>,
1118    layout: u8,
1119    strides: Vec<usize>,
1120}
1121
1122type NaryMemberPair = (u64, u64);
1123
1124#[derive(Clone, Debug, Eq, PartialEq)]
1125struct NaryPlanCacheKey {
1126    operands: Vec<NaryPlanCacheOperand>,
1127    output: Vec<String>,
1128}
1129
1130struct NaryCachedPlan {
1131    key: NaryPlanCacheKey,
1132    sequence: Vec<NaryMemberPair>,
1133}
1134
1135thread_local! {
1136    static NARY_PLAN_CACHE: RefCell<VecDeque<NaryCachedPlan>> =
1137        const { RefCell::new(VecDeque::new()) };
1138}
1139
1140fn nary_plan_cache_key(
1141    operands: &[NaryPlannerMetadata<'_>],
1142    output: &[ExpandedAxis<'_>],
1143) -> NaryPlanCacheKey {
1144    NaryPlanCacheKey {
1145        operands: operands
1146            .iter()
1147            .map(|operand| {
1148                let (layout, strides) = match &operand.layout {
1149                    NaryLayoutEstimate::Contiguous => (0, Vec::new()),
1150                    NaryLayoutEstimate::Strided(strides) => (1, strides.clone()),
1151                    NaryLayoutEstimate::Unsupported => (2, Vec::new()),
1152                };
1153                NaryPlanCacheOperand {
1154                    axes: operand
1155                        .axes
1156                        .iter()
1157                        .map(|(axis, _)| axis.display_name())
1158                        .collect(),
1159                    dims: operand.axes.iter().map(|(_, extent)| *extent).collect(),
1160                    layout,
1161                    strides,
1162                }
1163            })
1164            .collect(),
1165        output: output.iter().map(ExpandedAxis::display_name).collect(),
1166    }
1167}
1168
1169fn cached_nary_sequence(key: &NaryPlanCacheKey) -> Option<Vec<NaryMemberPair>> {
1170    NARY_PLAN_CACHE.with(|cache| {
1171        cache
1172            .borrow()
1173            .iter()
1174            .find(|candidate| candidate.key == *key)
1175            .map(|candidate| candidate.sequence.clone())
1176    })
1177}
1178
1179fn cache_nary_sequence(key: NaryPlanCacheKey, sequence: Vec<NaryMemberPair>) {
1180    NARY_PLAN_CACHE.with(|cache| {
1181        let mut cache = cache.borrow_mut();
1182        if cache.iter().any(|candidate| candidate.key == key) {
1183            return;
1184        }
1185        if cache.len() == 16 {
1186            cache.pop_front();
1187        }
1188        cache.push_back(NaryCachedPlan { key, sequence });
1189    });
1190}
1191
1192#[cfg(test)]
1193fn clear_nary_plan_cache_for_test() {
1194    NARY_PLAN_CACHE.with(|cache| cache.borrow_mut().clear());
1195}
1196
1197fn model_axis_extent(operand: &NaryPlannerMetadata<'_>, axis: ExpandedAxis<'_>) -> Option<usize> {
1198    operand
1199        .axes
1200        .iter()
1201        .find(|(candidate, _)| *candidate == axis)
1202        .map(|(_, extent)| *extent)
1203}
1204
1205fn model_pair_details<'a>(
1206    state: &[NaryPlannerMetadata<'a>],
1207    left: usize,
1208    right: usize,
1209    final_output: &[ExpandedAxis<'a>],
1210    global_axis_order: &[ExpandedAxis<'a>],
1211) -> Result<(NaryPairCost, NaryPlannerMetadata<'a>)> {
1212    let left_operand = &state[left];
1213    let right_operand = &state[right];
1214    let union = global_axis_order
1215        .iter()
1216        .copied()
1217        .filter(|&axis| {
1218            model_axis_extent(left_operand, axis).is_some()
1219                || model_axis_extent(right_operand, axis).is_some()
1220        })
1221        .collect::<Vec<_>>();
1222    let retained = union
1223        .iter()
1224        .copied()
1225        .filter(|&axis| {
1226            final_output.contains(&axis)
1227                || state.iter().enumerate().any(|(index, operand)| {
1228                    index != left && index != right && model_axis_extent(operand, axis).is_some()
1229                })
1230        })
1231        .collect::<Vec<_>>();
1232    let resolve = |axis| match (
1233        model_axis_extent(left_operand, axis),
1234        model_axis_extent(right_operand, axis),
1235    ) {
1236        (Some(left), Some(right)) => resolve_extent(&axis.display_name(), left, right),
1237        (Some(extent), None) | (None, Some(extent)) => Ok(extent),
1238        (None, None) => candle_core::bail!("n-ary model axis is absent from both operands"),
1239    };
1240    let left_axes = left_operand
1241        .axes
1242        .iter()
1243        .map(|(axis, _)| *axis)
1244        .collect::<Vec<_>>();
1245    let left_dims = left_operand
1246        .axes
1247        .iter()
1248        .map(|(_, extent)| *extent)
1249        .collect::<Vec<_>>();
1250    let right_axes = right_operand
1251        .axes
1252        .iter()
1253        .map(|(axis, _)| *axis)
1254        .collect::<Vec<_>>();
1255    let right_dims = right_operand
1256        .axes
1257        .iter()
1258        .map(|(_, extent)| *extent)
1259        .collect::<Vec<_>>();
1260    let graph = classify_expanded_binary_graph(
1261        BinaryGraphOperand {
1262            axes: &left_axes,
1263            dims: &left_dims,
1264            layout: left_operand.layout.as_binary(),
1265        },
1266        BinaryGraphOperand {
1267            axes: &right_axes,
1268            dims: &right_dims,
1269            layout: right_operand.layout.as_binary(),
1270        },
1271        &retained,
1272        4,
1273    )?;
1274    let classification = graph.plan;
1275    let canonical_axes = classification
1276        .batch
1277        .iter()
1278        .chain(&classification.left_free)
1279        .chain(&classification.right_free)
1280        .copied()
1281        .collect::<Vec<_>>();
1282    let axes = canonical_axes
1283        .iter()
1284        .copied()
1285        .map(|axis| resolve(axis).map(|extent| (axis, extent)))
1286        .collect::<Result<Vec<_>>>()?;
1287    Ok((
1288        NaryPairCost {
1289            flops: graph.work,
1290            output_elements: graph.output_elements,
1291            copy_bytes: graph.copy_bytes,
1292            submissions: graph.submissions,
1293        },
1294        NaryPlannerMetadata {
1295            stable_ordinal: left_operand
1296                .stable_ordinal
1297                .min(right_operand.stable_ordinal),
1298            axes,
1299            layout: graph.output_layout,
1300            members: left_operand.members | right_operand.members,
1301        },
1302    ))
1303}
1304
1305fn model_initial_metrics(state: &[NaryPlannerMetadata<'_>]) -> Result<NaryPlanMetrics> {
1306    let peak_live_elements = state.iter().try_fold(0_u128, |sum, operand| {
1307        sum.checked_add(operand.elements()?)
1308            .ok_or_else(|| candle_core::Error::msg("n-ary live estimate overflows u128"))
1309    })?;
1310    Ok(NaryPlanMetrics {
1311        flops: 0,
1312        intermediate_elements: 0,
1313        output_elements: 0,
1314        copy_bytes: 0,
1315        peak_live_elements,
1316        submissions: 0,
1317        score: 0,
1318    })
1319}
1320
1321fn model_accumulate(
1322    metrics: &mut NaryPlanMetrics,
1323    state: &[NaryPlannerMetadata<'_>],
1324    estimate: &NaryPairCost,
1325) -> Result<()> {
1326    let live = state.iter().try_fold(0_u128, |sum, operand| {
1327        sum.checked_add(operand.elements()?)
1328            .ok_or_else(|| candle_core::Error::msg("n-ary live estimate overflows u128"))
1329    })?;
1330    metrics.peak_live_elements = metrics.peak_live_elements.max(
1331        live.checked_add(estimate.output_elements)
1332            .ok_or_else(|| candle_core::Error::msg("n-ary peak estimate overflows u128"))?,
1333    );
1334    metrics.flops = metrics
1335        .flops
1336        .checked_add(estimate.flops)
1337        .ok_or_else(|| candle_core::Error::msg("n-ary FLOP estimate overflows u128"))?;
1338    metrics.intermediate_elements = metrics
1339        .intermediate_elements
1340        .checked_add(estimate.output_elements)
1341        .ok_or_else(|| candle_core::Error::msg("n-ary intermediate estimate overflows u128"))?;
1342    metrics.copy_bytes = metrics
1343        .copy_bytes
1344        .checked_add(estimate.copy_bytes)
1345        .ok_or_else(|| candle_core::Error::msg("n-ary copy estimate overflows u128"))?;
1346    metrics.submissions = metrics
1347        .submissions
1348        .checked_add(estimate.submissions)
1349        .ok_or_else(|| candle_core::Error::msg("n-ary submission estimate overflows u128"))?;
1350    metrics.output_elements = estimate.output_elements;
1351    Ok(())
1352}
1353
1354fn model_score(metrics: &NaryPlanMetrics) -> Result<u128> {
1355    [
1356        (metrics.flops, 1),
1357        (metrics.copy_bytes, 1),
1358        (metrics.intermediate_elements, 2),
1359        (metrics.peak_live_elements, 2),
1360        (metrics.submissions, 1_024),
1361    ]
1362    .into_iter()
1363    .try_fold(0_u128, |sum, (value, weight)| {
1364        sum.checked_add(
1365            value
1366                .checked_mul(weight)
1367                .ok_or_else(|| candle_core::Error::msg("n-ary weighted estimate overflows u128"))?,
1368        )
1369        .ok_or_else(|| candle_core::Error::msg("n-ary score estimate overflows u128"))
1370    })
1371}
1372
1373fn model_apply_pair<'a>(
1374    state: &mut Vec<NaryPlannerMetadata<'a>>,
1375    left: usize,
1376    right: usize,
1377    output: NaryPlannerMetadata<'a>,
1378) {
1379    state.remove(right);
1380    state.remove(left);
1381    state.insert(left, output);
1382}
1383
1384fn model_greedy_plan<'a>(
1385    operands: &[NaryPlannerMetadata<'a>],
1386    final_output: &[ExpandedAxis<'a>],
1387    global: &[ExpandedAxis<'a>],
1388) -> Result<NaryContractionPlan<'a>> {
1389    let mut state = operands.to_vec();
1390    let mut metrics = model_initial_metrics(&state)?;
1391    let mut steps = Vec::new();
1392    while state.len() > 1 {
1393        let mut best = None;
1394        for left in 0..state.len() - 1 {
1395            for right in left + 1..state.len() {
1396                let (estimate, output) =
1397                    model_pair_details(&state, left, right, final_output, global)?;
1398                let key = (
1399                    estimate.output_elements,
1400                    estimate.flops,
1401                    state[left].stable_ordinal,
1402                    state[right].stable_ordinal,
1403                    left,
1404                    right,
1405                );
1406                if best.as_ref().is_none_or(
1407                    |(bl, br, be, _): &(usize, usize, NaryPairCost, NaryPlannerMetadata<'a>)| {
1408                        key < (
1409                            be.output_elements,
1410                            be.flops,
1411                            state[*bl].stable_ordinal,
1412                            state[*br].stable_ordinal,
1413                            *bl,
1414                            *br,
1415                        )
1416                    },
1417                ) {
1418                    best = Some((left, right, estimate, output));
1419                }
1420            }
1421        }
1422        let (left, right, estimate, output) =
1423            best.ok_or_else(|| candle_core::Error::msg("n-ary greedy model found no pair"))?;
1424        model_accumulate(&mut metrics, &state, &estimate)?;
1425        steps.push(NaryPlanStep {
1426            members: (state[left].members, state[right].members),
1427            output_axes: output.axes.iter().map(|(axis, _)| *axis).collect(),
1428            estimate,
1429        });
1430        model_apply_pair(&mut state, left, right, output);
1431    }
1432    metrics.score = model_score(&metrics)?;
1433    Ok(NaryContractionPlan { steps, metrics })
1434}
1435
1436fn model_sequence_plan<'a>(
1437    operands: &[NaryPlannerMetadata<'a>],
1438    final_output: &[ExpandedAxis<'a>],
1439    global: &[ExpandedAxis<'a>],
1440    sequence: &[(u64, u64)],
1441) -> Result<NaryContractionPlan<'a>> {
1442    if sequence.len() + 1 != operands.len() {
1443        candle_core::bail!("cached n-ary sequence has the wrong number of steps")
1444    }
1445    let mut state = operands.to_vec();
1446    let mut metrics = model_initial_metrics(&state)?;
1447    let mut steps = Vec::with_capacity(sequence.len());
1448    for &members in sequence {
1449        let left = state
1450            .iter()
1451            .position(|operand| operand.members == members.0)
1452            .ok_or_else(|| candle_core::Error::msg("cached n-ary left members are not live"))?;
1453        let right = state
1454            .iter()
1455            .position(|operand| operand.members == members.1)
1456            .ok_or_else(|| candle_core::Error::msg("cached n-ary right members are not live"))?;
1457        if left >= right {
1458            candle_core::bail!("cached n-ary members are not in stable order")
1459        }
1460        let (estimate, pair_output) =
1461            model_pair_details(&state, left, right, final_output, global)?;
1462        model_accumulate(&mut metrics, &state, &estimate)?;
1463        steps.push(NaryPlanStep {
1464            members,
1465            output_axes: pair_output.axes.iter().map(|(axis, _)| *axis).collect(),
1466            estimate,
1467        });
1468        model_apply_pair(&mut state, left, right, pair_output);
1469    }
1470    metrics.score = model_score(&metrics)?;
1471    Ok(NaryContractionPlan { steps, metrics })
1472}
1473
1474fn model_exact_search<'a>(
1475    operands: &[NaryPlannerMetadata<'a>],
1476    final_output: &[ExpandedAxis<'a>],
1477    global: &[ExpandedAxis<'a>],
1478) -> Result<NaryContractionPlan<'a>> {
1479    fn visit<'a>(
1480        state: Vec<NaryPlannerMetadata<'a>>,
1481        steps: Vec<NaryPlanStep<'a>>,
1482        metrics: NaryPlanMetrics,
1483        output: &[ExpandedAxis<'a>],
1484        global: &[ExpandedAxis<'a>],
1485        best: &mut Option<NaryContractionPlan<'a>>,
1486    ) -> Result<()> {
1487        if state.len() == 1 {
1488            let mut metrics = metrics;
1489            metrics.score = model_score(&metrics)?;
1490            let candidate = NaryContractionPlan { steps, metrics };
1491            let masks = |plan: &NaryContractionPlan<'a>| {
1492                plan.steps
1493                    .iter()
1494                    .map(|step| step.members)
1495                    .collect::<Vec<_>>()
1496            };
1497            if best.as_ref().is_none_or(|current| {
1498                (candidate.metrics.score, masks(&candidate))
1499                    < (current.metrics.score, masks(current))
1500            }) {
1501                *best = Some(candidate);
1502            }
1503            return Ok(());
1504        }
1505        for left in 0..state.len() - 1 {
1506            for right in left + 1..state.len() {
1507                let (estimate, pair_output) =
1508                    model_pair_details(&state, left, right, output, global)?;
1509                let mut next_state = state.clone();
1510                let mut next_metrics = metrics.clone();
1511                model_accumulate(&mut next_metrics, &state, &estimate)?;
1512                let mut next_steps = steps.clone();
1513                next_steps.push(NaryPlanStep {
1514                    members: (state[left].members, state[right].members),
1515                    output_axes: pair_output.axes.iter().map(|(axis, _)| *axis).collect(),
1516                    estimate,
1517                });
1518                model_apply_pair(&mut next_state, left, right, pair_output);
1519                visit(next_state, next_steps, next_metrics, output, global, best)?;
1520            }
1521        }
1522        Ok(())
1523    }
1524    if !(3..=4).contains(&operands.len()) {
1525        candle_core::bail!("exact n-ary planner supports arity 3 through 4")
1526    }
1527    let metrics = model_initial_metrics(operands)?;
1528    let mut best = None;
1529    visit(
1530        operands.to_vec(),
1531        Vec::new(),
1532        metrics,
1533        final_output,
1534        global,
1535        &mut best,
1536    )?;
1537    best.ok_or_else(|| candle_core::Error::msg("exact n-ary planner found no plan"))
1538}
1539
1540fn select_layout_aware_plan<'a>(
1541    operands: &[NaryPlannerMetadata<'a>],
1542    final_output: &[ExpandedAxis<'a>],
1543    dtype: DType,
1544    cpu: bool,
1545) -> NaryPlannerDecision<'a> {
1546    if !(3..=4).contains(&operands.len()) {
1547        return NaryPlannerDecision::Greedy(NaryGreedyReason::Arity);
1548    }
1549    if dtype != DType::F32 {
1550        return NaryPlannerDecision::Greedy(NaryGreedyReason::DType);
1551    }
1552    if !cpu {
1553        return NaryPlannerDecision::Greedy(NaryGreedyReason::Backend);
1554    }
1555    if operands
1556        .iter()
1557        .any(|operand| operand.layout == NaryLayoutEstimate::Unsupported)
1558    {
1559        return NaryPlannerDecision::Greedy(NaryGreedyReason::UnsupportedLayout);
1560    }
1561    if operands
1562        .iter()
1563        .any(|operand| operand.axes.iter().any(|(_, extent)| *extent == 0))
1564    {
1565        return NaryPlannerDecision::Greedy(NaryGreedyReason::BelowFlopThreshold);
1566    }
1567    if !EXACT_NARY_EXECUTION_CALIBRATED {
1568        return NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration);
1569    }
1570    let mut global = Vec::new();
1571    for operand in operands {
1572        for &(axis, _) in &operand.axes {
1573            if !global.contains(&axis) {
1574                global.push(axis);
1575            }
1576        }
1577    }
1578    let cache_key = nary_plan_cache_key(operands, final_output);
1579    if let Some(sequence) = cached_nary_sequence(&cache_key)
1580        && let Ok(plan) = model_sequence_plan(operands, final_output, &global, &sequence)
1581    {
1582        return NaryPlannerDecision::Exact(plan);
1583    }
1584    let greedy = match model_greedy_plan(operands, final_output, &global) {
1585        Ok(plan) => plan,
1586        Err(_) => return NaryPlannerDecision::Greedy(NaryGreedyReason::ModelFailure),
1587    };
1588    if greedy.metrics.flops < 100_000 {
1589        return NaryPlannerDecision::Greedy(NaryGreedyReason::BelowFlopThreshold);
1590    }
1591    match model_exact_search(operands, final_output, &global) {
1592        Ok(plan) => {
1593            cache_nary_sequence(
1594                cache_key,
1595                plan.steps.iter().map(|step| step.members).collect(),
1596            );
1597            NaryPlannerDecision::Exact(plan)
1598        }
1599        Err(_) => NaryPlannerDecision::Greedy(NaryGreedyReason::ModelFailure),
1600    }
1601}
1602
1603#[cfg(test)]
1604fn select_layout_aware_plan_for_test<'a>(
1605    operands: &[NaryPlannerMetadata<'a>],
1606    final_output: &[&'a str],
1607    dtype: DType,
1608    cpu: bool,
1609) -> NaryPlannerDecision<'a> {
1610    select_layout_aware_plan(
1611        operands,
1612        &final_output
1613            .iter()
1614            .copied()
1615            .map(ExpandedAxis::Named)
1616            .collect::<Vec<_>>(),
1617        dtype,
1618        cpu,
1619    )
1620}
1621
1622#[cfg(test)]
1623fn plan_layout_exact_for_test<'a>(
1624    operands: &[NaryPlannerMetadata<'a>],
1625    final_output: &[&'a str],
1626) -> Result<NaryContractionPlan<'a>> {
1627    let mut global = Vec::new();
1628    for operand in operands {
1629        for &(axis, _) in &operand.axes {
1630            if !global.contains(&axis) {
1631                global.push(axis);
1632            }
1633        }
1634    }
1635    model_exact_search(
1636        operands,
1637        &final_output
1638            .iter()
1639            .copied()
1640            .map(ExpandedAxis::Named)
1641            .collect::<Vec<_>>(),
1642        &global,
1643    )
1644}
1645
1646#[derive(Debug)]
1647struct PairEstimate<'a> {
1648    left: usize,
1649    right: usize,
1650    output_axes: Vec<ExpandedAxis<'a>>,
1651    output_elements: u128,
1652    flops: u128,
1653}
1654
1655fn stable_axis_order<'a>(operands: &[PlannedOperand<'a>]) -> Vec<ExpandedAxis<'a>> {
1656    let mut axes = Vec::new();
1657    let mut ordered = operands.iter().collect::<Vec<_>>();
1658    ordered.sort_by_key(|operand| operand.stable_ordinal);
1659    for operand in ordered {
1660        for &axis in &operand.axes {
1661            if !axes.contains(&axis) {
1662                axes.push(axis);
1663            }
1664        }
1665    }
1666    axes
1667}
1668
1669fn validate_nary_broadcasts(operands: &[PlannedOperand<'_>]) -> Result<()> {
1670    let mut dimensions = Vec::<(ExpandedAxis<'_>, usize)>::new();
1671    for operand in operands {
1672        for (&axis, &extent) in operand.axes.iter().zip(operand.tensor.dims()) {
1673            if let Some((_, resolved)) = dimensions
1674                .iter_mut()
1675                .find(|(candidate, _)| *candidate == axis)
1676            {
1677                *resolved = resolve_extent(&axis.display_name(), *resolved, extent)?;
1678            } else {
1679                dimensions.push((axis, extent));
1680            }
1681        }
1682    }
1683    Ok(())
1684}
1685
1686#[cfg(test)]
1687fn select_nary_pair<'a>(
1688    operands: &[PlannedOperand<'a>],
1689    final_output: &[ExpandedAxis<'a>],
1690) -> Result<PairEstimate<'a>> {
1691    let global_axis_order = stable_axis_order(operands);
1692    select_nary_pair_with_order(operands, final_output, &global_axis_order)
1693}
1694
1695fn select_nary_pair_with_order<'a>(
1696    operands: &[PlannedOperand<'a>],
1697    final_output: &[ExpandedAxis<'a>],
1698    global_axis_order: &[ExpandedAxis<'a>],
1699) -> Result<PairEstimate<'a>> {
1700    if operands.len() < 2 {
1701        candle_core::bail!("invalid n-ary einsum planner state: fewer than two operands")
1702    }
1703    let mut best: Option<PairEstimate<'a>> = None;
1704    for left in 0..operands.len() - 1 {
1705        for right in left + 1..operands.len() {
1706            let candidate =
1707                estimate_pair_with_order(operands, left, right, final_output, global_axis_order)?;
1708            let candidate_key = (
1709                candidate.output_elements,
1710                candidate.flops,
1711                operands[left].stable_ordinal,
1712                operands[right].stable_ordinal,
1713                left,
1714                right,
1715            );
1716            let replace = best.as_ref().is_none_or(|current| {
1717                candidate_key
1718                    < (
1719                        current.output_elements,
1720                        current.flops,
1721                        operands[current.left].stable_ordinal,
1722                        operands[current.right].stable_ordinal,
1723                        current.left,
1724                        current.right,
1725                    )
1726            });
1727            if replace {
1728                best = Some(candidate);
1729            }
1730        }
1731    }
1732    best.ok_or_else(|| candle_core::Error::msg("n-ary einsum planner found no operand pair"))
1733}
1734
1735#[cfg(test)]
1736fn estimate_pair<'a>(
1737    operands: &[PlannedOperand<'a>],
1738    left: usize,
1739    right: usize,
1740    final_output: &[ExpandedAxis<'a>],
1741) -> Result<PairEstimate<'a>> {
1742    let global_axis_order = stable_axis_order(operands);
1743    estimate_pair_with_order(operands, left, right, final_output, &global_axis_order)
1744}
1745
1746fn estimate_pair_with_order<'a>(
1747    operands: &[PlannedOperand<'a>],
1748    left: usize,
1749    right: usize,
1750    final_output: &[ExpandedAxis<'a>],
1751    global_axis_order: &[ExpandedAxis<'a>],
1752) -> Result<PairEstimate<'a>> {
1753    let left_operand = operands.get(left).ok_or_else(|| {
1754        candle_core::Error::msg(format!(
1755            "n-ary einsum planner left index {left} is out of range"
1756        ))
1757    })?;
1758    let right_operand = operands.get(right).ok_or_else(|| {
1759        candle_core::Error::msg(format!(
1760            "n-ary einsum planner right index {right} is out of range"
1761        ))
1762    })?;
1763    if left >= right {
1764        candle_core::bail!("n-ary einsum planner pair must be ordered, received ({left}, {right})")
1765    }
1766
1767    let mut pair_axes = Vec::new();
1768    for &axis in left_operand.axes.iter().chain(&right_operand.axes) {
1769        if !pair_axes.contains(&axis) {
1770            pair_axes.push(axis);
1771        }
1772    }
1773    let mut live_axes = final_output.to_vec();
1774    for (index, operand) in operands.iter().enumerate() {
1775        if index != left && index != right {
1776            for &axis in &operand.axes {
1777                if !live_axes.contains(&axis) {
1778                    live_axes.push(axis);
1779                }
1780            }
1781        }
1782    }
1783    let output_axes = global_axis_order
1784        .iter()
1785        .copied()
1786        .filter(|axis| pair_axes.contains(axis) && live_axes.contains(axis))
1787        .collect::<Vec<_>>();
1788    let output_extents = output_axes
1789        .iter()
1790        .map(|axis| pair_axis_extent(left_operand, right_operand, *axis))
1791        .collect::<Result<Vec<_>>>()?;
1792    let flop_extents = pair_axes
1793        .iter()
1794        .map(|axis| pair_axis_extent(left_operand, right_operand, *axis))
1795        .collect::<Result<Vec<_>>>()?;
1796    let output_elements = checked_nary_product(&output_extents).map_err(|error| {
1797        error.context(format!(
1798            "einsum n-ary pair ({left}, {right}) intermediate estimate"
1799        ))
1800    })?;
1801    let flops = checked_nary_product(&flop_extents).map_err(|error| {
1802        error.context(format!("einsum n-ary pair ({left}, {right}) FLOP estimate"))
1803    })?;
1804    Ok(PairEstimate {
1805        left,
1806        right,
1807        output_axes,
1808        output_elements,
1809        flops,
1810    })
1811}
1812
1813fn pair_axis_extent(
1814    left: &PlannedOperand<'_>,
1815    right: &PlannedOperand<'_>,
1816    axis: ExpandedAxis<'_>,
1817) -> Result<usize> {
1818    let left_extent = left
1819        .axes
1820        .iter()
1821        .position(|candidate| *candidate == axis)
1822        .map(|position| left.tensor.dims()[position]);
1823    let right_extent = right
1824        .axes
1825        .iter()
1826        .position(|candidate| *candidate == axis)
1827        .map(|position| right.tensor.dims()[position]);
1828    match (left_extent, right_extent) {
1829        (Some(left), Some(right)) => resolve_extent(&axis.display_name(), left, right),
1830        (Some(extent), None) | (None, Some(extent)) => Ok(extent),
1831        (None, None) => candle_core::bail!(
1832            "invalid n-ary einsum planner axis `{}` is absent from both operands",
1833            axis.display_name()
1834        ),
1835    }
1836}
1837
1838fn checked_nary_product(extents: &[usize]) -> Result<u128> {
1839    if extents.contains(&0) {
1840        return Ok(0);
1841    }
1842    extents.iter().try_fold(1_u128, |product, &extent| {
1843        product
1844            .checked_mul(extent as u128)
1845            .ok_or_else(|| candle_core::Error::msg("einsum n-ary cost estimate overflows u128"))
1846    })
1847}
1848
1849#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1850enum ExpandedAxis<'a> {
1851    Named(&'a str),
1852    Ellipsis(usize),
1853}
1854
1855impl ExpandedAxis<'_> {
1856    fn display_name(&self) -> String {
1857        match self {
1858            Self::Named(name) => (*name).to_owned(),
1859            Self::Ellipsis(index) => format!("..[{index}]"),
1860        }
1861    }
1862}
1863
1864fn ellipsis_capture(
1865    operand: &Tensor,
1866    operand_index: usize,
1867    pattern: EinsumAxisPattern<'_>,
1868) -> Result<usize> {
1869    if let Some(position) = pattern.ellipsis_position {
1870        if position > pattern.labels.len() {
1871            candle_core::bail!(
1872                "invalid ellipsis einsum plan: operand {operand_index} ellipsis position {position} exceeds {} explicit labels",
1873                pattern.labels.len()
1874            )
1875        }
1876        operand.rank().checked_sub(pattern.labels.len()).ok_or_else(|| {
1877            candle_core::Error::msg(format!(
1878                "einsum operand {operand_index} has rank {}, but {} explicit axes leave no valid ellipsis capture",
1879                operand.rank(),
1880                pattern.labels.len()
1881            ))
1882        })
1883    } else if operand.rank() == pattern.labels.len() {
1884        Ok(0)
1885    } else {
1886        candle_core::bail!(
1887            "einsum operand {operand_index} has rank {}, expected {} because its axis list has no ellipsis",
1888            operand.rank(),
1889            pattern.labels.len()
1890        )
1891    }
1892}
1893
1894fn normalize_ellipsis_operand(
1895    operand: &Tensor,
1896    pattern: EinsumAxisPattern<'_>,
1897    capture: usize,
1898    maximum_capture: usize,
1899    operand_index: usize,
1900) -> Result<Tensor> {
1901    let missing = maximum_capture.checked_sub(capture).ok_or_else(|| {
1902        candle_core::Error::msg("invalid ellipsis einsum plan: capture exceeds maximum")
1903    })?;
1904    let insertion = pattern.ellipsis_position.unwrap_or(0);
1905    let mut normalized = operand.clone();
1906    for _ in 0..missing {
1907        normalized = normalized.unsqueeze(insertion).map_err(|error| {
1908            error.context(format!(
1909                "einsum operand {operand_index} ellipsis right-alignment"
1910            ))
1911        })?;
1912    }
1913    Ok(normalized)
1914}
1915
1916#[derive(Debug, PartialEq, Eq)]
1917enum RepeatedAxisLoweringPlan {
1918    Sequential,
1919    OriginalFlatGather {
1920        output_shape: Vec<usize>,
1921        offsets: Vec<u32>,
1922    },
1923}
1924
1925/// Caller-owned, device-bound indices for repeated-axis diagonal extraction.
1926///
1927/// Axis ids represent the input side of an einsum equation: equal ids are the
1928/// repeated labels whose diagonal is retained, and first occurrence order
1929/// determines the output axes. Preparing once avoids rebuilding and uploading
1930/// the same `u32` index tensor for repeated calls with the same shape.
1931#[derive(Debug)]
1932pub struct PreparedDiagonalPlan {
1933    input_shape: Vec<usize>,
1934    axis_ids: Vec<usize>,
1935    output_shape: Vec<usize>,
1936    indices: Tensor,
1937}
1938
1939impl PreparedDiagonalPlan {
1940    /// Prepares reusable diagonal indices for one exact shape and device.
1941    pub fn new(input_shape: &[usize], axis_ids: &[usize], device: &Device) -> Result<Self> {
1942        if input_shape.len() != axis_ids.len() {
1943            candle_core::bail!(
1944                "prepared diagonal shape rank {} does not match axis-id rank {}",
1945                input_shape.len(),
1946                axis_ids.len()
1947            )
1948        }
1949        let axes = axis_ids
1950            .iter()
1951            .copied()
1952            .map(ExpandedAxis::Ellipsis)
1953            .collect::<Vec<_>>();
1954        validate_repeated_extents(input_shape, &axes, 0)?;
1955        if !(0..axis_ids.len()).any(|position| axis_ids[..position].contains(&axis_ids[position])) {
1956            candle_core::bail!("prepared diagonal plan requires at least one repeated axis id")
1957        }
1958        let Some((output_shape, offsets)) = original_flat_gather_offsets(input_shape, &axes, 0)?
1959        else {
1960            candle_core::bail!("prepared diagonal offsets exceed the supported u32 index range")
1961        };
1962        let index_count = offsets.len();
1963        let indices = Tensor::from_vec(offsets, index_count, device)
1964            .map_err(|error| error.context("prepared diagonal device-local indices"))?;
1965        Ok(Self {
1966            input_shape: input_shape.to_vec(),
1967            axis_ids: axis_ids.to_vec(),
1968            output_shape,
1969            indices,
1970        })
1971    }
1972
1973    /// Convenience constructor for `i i ... -> i` extraction.
1974    pub fn repeated(extent: usize, multiplicity: usize, device: &Device) -> Result<Self> {
1975        if multiplicity < 2 {
1976            candle_core::bail!("prepared repeated diagonal requires multiplicity of at least two")
1977        }
1978        Self::new(&vec![extent; multiplicity], &vec![0; multiplicity], device)
1979    }
1980
1981    /// Convenience constructor for `i j i j -> i j` extraction.
1982    pub fn interleaved(first_extent: usize, second_extent: usize, device: &Device) -> Result<Self> {
1983        Self::new(
1984            &[first_extent, second_extent, first_extent, second_extent],
1985            &[0, 1, 0, 1],
1986            device,
1987        )
1988    }
1989
1990    /// Extracts the prepared diagonal from one contiguous tensor.
1991    pub fn execute(&self, input: &Tensor) -> Result<Tensor> {
1992        if input.dims() != self.input_shape {
1993            candle_core::bail!(
1994                "prepared diagonal plan expects shape {:?}, received {:?}",
1995                self.input_shape,
1996                input.dims()
1997            )
1998        }
1999        if !input.is_contiguous() {
2000            candle_core::bail!("prepared diagonal plan requires contiguous input")
2001        }
2002        if !input.device().same_device(self.indices.device()) {
2003            candle_core::bail!("prepared diagonal plan and input are on different devices")
2004        }
2005        input
2006            .flatten_all()
2007            .and_then(|flat| flat.index_select(&self.indices, 0))
2008            .and_then(|selected| selected.reshape(self.output_shape.as_slice()))
2009            .map_err(|error| error.context("prepared diagonal extraction"))
2010    }
2011
2012    /// Exact input shape captured by this plan.
2013    #[must_use]
2014    pub fn input_shape(&self) -> &[usize] {
2015        &self.input_shape
2016    }
2017
2018    /// Axis ids captured from the input-side equation.
2019    #[must_use]
2020    pub fn axis_ids(&self) -> &[usize] {
2021        &self.axis_ids
2022    }
2023
2024    /// Unique-axis output shape in first-occurrence order.
2025    #[must_use]
2026    pub fn output_shape(&self) -> &[usize] {
2027        &self.output_shape
2028    }
2029
2030    /// Device index representation used by the prepared gather.
2031    #[must_use]
2032    pub const fn index_dtype(&self) -> DType {
2033        DType::U32
2034    }
2035
2036    /// Prepared device-local indices, exposed for diagnostics and benchmarking.
2037    #[must_use]
2038    pub fn indices(&self) -> &Tensor {
2039        &self.indices
2040    }
2041}
2042
2043fn validate_repeated_extents(
2044    dims: &[usize],
2045    axes: &[ExpandedAxis<'_>],
2046    operand_index: usize,
2047) -> Result<()> {
2048    for (position, axis) in axes.iter().copied().enumerate() {
2049        let Some(previous) = axes[..position]
2050            .iter()
2051            .position(|candidate| *candidate == axis)
2052        else {
2053            continue;
2054        };
2055        let extent = dims[previous];
2056        let other = dims[position];
2057        if other != extent {
2058            candle_core::bail!(
2059                "einsum operand {operand_index} repeated label `{}` has unequal extents {extent} and {other}",
2060                axis.display_name()
2061            )
2062        }
2063    }
2064    Ok(())
2065}
2066
2067fn checked_contiguous_strides(dims: &[usize]) -> Option<Vec<usize>> {
2068    let mut product = 1_usize;
2069    let mut strides = vec![0; dims.len()];
2070    for (position, &extent) in dims.iter().enumerate().rev() {
2071        strides[position] = product;
2072        product = product.checked_mul(extent)?;
2073    }
2074    Some(strides)
2075}
2076
2077fn simulated_contiguous(dims: &[usize], strides: &[usize]) -> Option<bool> {
2078    let mut expected = 1_usize;
2079    for (&extent, &stride) in dims.iter().zip(strides).rev() {
2080        if extent > 1 && stride != expected {
2081            return Some(false);
2082        }
2083        expected = expected.checked_mul(extent)?;
2084    }
2085    Some(true)
2086}
2087
2088fn sequential_diagonal_would_materialize(
2089    original_dims: &[usize],
2090    original_axes: &[ExpandedAxis<'_>],
2091) -> Option<bool> {
2092    let mut dims = original_dims.to_vec();
2093    let mut axes = original_axes.to_vec();
2094    let mut strides = checked_contiguous_strides(&dims)?;
2095    loop {
2096        let Some(repeated_axis) = axes
2097            .iter()
2098            .copied()
2099            .find(|axis| axes.iter().filter(|candidate| **candidate == *axis).count() > 1)
2100        else {
2101            return Some(false);
2102        };
2103        let positions = axes
2104            .iter()
2105            .enumerate()
2106            .filter_map(|(position, axis)| (*axis == repeated_axis).then_some(position))
2107            .collect::<Vec<_>>();
2108        let other_positions = (0..axes.len())
2109            .filter(|position| !positions.contains(position))
2110            .collect::<Vec<_>>();
2111        let permutation = positions
2112            .iter()
2113            .chain(&other_positions)
2114            .copied()
2115            .collect::<Vec<_>>();
2116        let adjacent_dims = permutation
2117            .iter()
2118            .map(|&position| dims[position])
2119            .collect::<Vec<_>>();
2120        let adjacent_strides = permutation
2121            .iter()
2122            .map(|&position| strides[position])
2123            .collect::<Vec<_>>();
2124        if !simulated_contiguous(&adjacent_dims, &adjacent_strides)? {
2125            return Some(true);
2126        }
2127
2128        let selected_dims = std::iter::once(dims[positions[0]])
2129            .chain(other_positions.iter().map(|&position| dims[position]))
2130            .collect::<Vec<_>>();
2131        let selected_strides = checked_contiguous_strides(&selected_dims)?;
2132        let result_order = std::iter::once(positions[0])
2133            .chain(other_positions.iter().copied())
2134            .collect::<Vec<_>>();
2135        let desired_order = (0..axes.len())
2136            .filter(|position| !positions[1..].contains(position))
2137            .collect::<Vec<_>>();
2138        let restoration = desired_order
2139            .iter()
2140            .map(|position| {
2141                result_order
2142                    .iter()
2143                    .position(|candidate| candidate == position)
2144            })
2145            .collect::<Option<Vec<_>>>()?;
2146        dims = restoration
2147            .iter()
2148            .map(|&position| selected_dims[position])
2149            .collect();
2150        strides = restoration
2151            .iter()
2152            .map(|&position| selected_strides[position])
2153            .collect();
2154        axes = desired_order
2155            .iter()
2156            .map(|&position| axes[position])
2157            .collect();
2158    }
2159}
2160
2161fn sequential_adjacency_is_always_identity(original_axes: &[ExpandedAxis<'_>]) -> bool {
2162    if original_axes
2163        .first()
2164        .is_some_and(|first| original_axes.iter().all(|axis| axis == first))
2165    {
2166        return true;
2167    }
2168    let mut axes = original_axes.to_vec();
2169    loop {
2170        let Some(repeated_axis) = axes
2171            .iter()
2172            .copied()
2173            .find(|axis| axes.iter().filter(|candidate| **candidate == *axis).count() > 1)
2174        else {
2175            return true;
2176        };
2177        let positions = axes
2178            .iter()
2179            .enumerate()
2180            .filter_map(|(position, axis)| (*axis == repeated_axis).then_some(position))
2181            .collect::<Vec<_>>();
2182        if positions.iter().copied().ne(0..positions.len()) {
2183            return false;
2184        }
2185        axes = axes
2186            .iter()
2187            .enumerate()
2188            .filter_map(|(position, axis)| (!positions[1..].contains(&position)).then_some(*axis))
2189            .collect();
2190    }
2191}
2192
2193fn original_flat_gather_offsets(
2194    dims: &[usize],
2195    axes: &[ExpandedAxis<'_>],
2196    operand_index: usize,
2197) -> Result<Option<(Vec<usize>, Vec<u32>)>> {
2198    let mut unique_axes = Vec::new();
2199    let mut unique_positions = Vec::with_capacity(axes.len());
2200    for axis in axes.iter().copied() {
2201        let position =
2202            if let Some(position) = unique_axes.iter().position(|candidate| *candidate == axis) {
2203                position
2204            } else {
2205                unique_axes.push(axis);
2206                unique_axes.len() - 1
2207            };
2208        unique_positions.push(position);
2209    }
2210    let output_shape = unique_axes
2211        .iter()
2212        .map(|axis| {
2213            let position = axes
2214                .iter()
2215                .position(|candidate| candidate == axis)
2216                .expect("a unique diagonal axis came from the original axes");
2217            dims[position]
2218        })
2219        .collect::<Vec<_>>();
2220
2221    if output_shape.contains(&0) {
2222        return Ok(Some((output_shape, Vec::new())));
2223    }
2224    let Some(original_strides) = checked_contiguous_strides(dims) else {
2225        return Ok(None);
2226    };
2227    let Some(output_elements) = output_shape
2228        .iter()
2229        .try_fold(1_usize, |product, &extent| product.checked_mul(extent))
2230    else {
2231        return Ok(None);
2232    };
2233    let mut maximum_offset = 0_usize;
2234    for (position, &stride) in original_strides.iter().enumerate() {
2235        let coordinate = output_shape[unique_positions[position]] - 1;
2236        let Some(contribution) = coordinate.checked_mul(stride) else {
2237            return Ok(None);
2238        };
2239        let Some(next) = maximum_offset.checked_add(contribution) else {
2240            return Ok(None);
2241        };
2242        maximum_offset = next;
2243    }
2244    if maximum_offset >= u32::MAX as usize {
2245        return Ok(None);
2246    }
2247
2248    let Some(output_strides) = checked_contiguous_strides(&output_shape) else {
2249        return Ok(None);
2250    };
2251    let mut offsets = Vec::new();
2252    offsets
2253        .try_reserve_exact(output_elements)
2254        .map_err(|error| {
2255            candle_core::Error::msg(format!(
2256                "einsum operand {operand_index} diagonal offset allocation failed: {error}"
2257            ))
2258        })?;
2259    for output_index in 0..output_elements {
2260        let mut offset = 0_usize;
2261        for (position, &stride) in original_strides.iter().enumerate() {
2262            let unique_position = unique_positions[position];
2263            let coordinate =
2264                (output_index / output_strides[unique_position]) % output_shape[unique_position];
2265            offset += coordinate * stride;
2266        }
2267        offsets.push(u32::try_from(offset).expect("maximum checked original-layout offset"));
2268    }
2269    Ok(Some((output_shape, offsets)))
2270}
2271
2272fn plan_repeated_axis_lowering(
2273    dims: &[usize],
2274    axes: &[ExpandedAxis<'_>],
2275    original_contiguous: bool,
2276    operand_index: usize,
2277) -> Result<RepeatedAxisLoweringPlan> {
2278    validate_repeated_extents(dims, axes, operand_index)?;
2279    if !original_contiguous
2280        || sequential_adjacency_is_always_identity(axes)
2281        || sequential_diagonal_would_materialize(dims, axes) != Some(true)
2282    {
2283        return Ok(RepeatedAxisLoweringPlan::Sequential);
2284    }
2285    let Some((output_shape, offsets)) = original_flat_gather_offsets(dims, axes, operand_index)?
2286    else {
2287        return Ok(RepeatedAxisLoweringPlan::Sequential);
2288    };
2289    Ok(RepeatedAxisLoweringPlan::OriginalFlatGather {
2290        output_shape,
2291        offsets,
2292    })
2293}
2294
2295fn original_flat_diagonal_gather(
2296    operand: &Tensor,
2297    output_shape: &[usize],
2298    offsets: Vec<u32>,
2299    operand_index: usize,
2300) -> Result<Tensor> {
2301    let index_count = offsets.len();
2302    let indices = Tensor::from_vec(offsets, index_count, operand.device()).map_err(|error| {
2303        error.context(format!(
2304            "einsum operand {operand_index} device-local combined diagonal indices"
2305        ))
2306    })?;
2307    operand
2308        .flatten_all()
2309        .map_err(|error| {
2310            error.context(format!(
2311                "einsum operand {operand_index} original-layout diagonal flatten"
2312            ))
2313        })?
2314        .index_select(&indices, 0)
2315        .map_err(|error| {
2316            error.context(format!(
2317                "einsum operand {operand_index} differentiable combined diagonal selection"
2318            ))
2319        })?
2320        .reshape(output_shape)
2321        .map_err(|error| {
2322            error.context(format!(
2323                "einsum operand {operand_index} combined diagonal reshape"
2324            ))
2325        })
2326}
2327
2328fn normalize_repeated_axes<'a>(
2329    mut operand: Tensor,
2330    mut axes: Vec<ExpandedAxis<'a>>,
2331    operand_index: usize,
2332) -> Result<(Tensor, Vec<ExpandedAxis<'a>>)> {
2333    match plan_repeated_axis_lowering(
2334        operand.dims(),
2335        &axes,
2336        operand.is_contiguous(),
2337        operand_index,
2338    )? {
2339        RepeatedAxisLoweringPlan::Sequential => {}
2340        RepeatedAxisLoweringPlan::OriginalFlatGather {
2341            output_shape,
2342            offsets,
2343        } => {
2344            let mut unique_axes = Vec::new();
2345            for axis in axes {
2346                if !unique_axes.contains(&axis) {
2347                    unique_axes.push(axis);
2348                }
2349            }
2350            return Ok((
2351                original_flat_diagonal_gather(&operand, &output_shape, offsets, operand_index)?,
2352                unique_axes,
2353            ));
2354        }
2355    }
2356    loop {
2357        let Some(repeated_axis) = axes
2358            .iter()
2359            .copied()
2360            .find(|axis| axes.iter().filter(|candidate| **candidate == *axis).count() > 1)
2361        else {
2362            return Ok((operand, axes));
2363        };
2364        let positions = axes
2365            .iter()
2366            .enumerate()
2367            .filter_map(|(position, axis)| (*axis == repeated_axis).then_some(position))
2368            .collect::<Vec<_>>();
2369        let extent = operand.dims()[positions[0]];
2370
2371        let other_positions = (0..axes.len())
2372            .filter(|position| !positions.contains(position))
2373            .collect::<Vec<_>>();
2374        let adjacency_permutation = positions
2375            .iter()
2376            .chain(&other_positions)
2377            .copied()
2378            .collect::<Vec<_>>();
2379        let device = operand.device().clone();
2380        let adjacent = if adjacency_permutation.iter().copied().eq(0..axes.len()) {
2381            operand
2382        } else {
2383            operand
2384                .permute(adjacency_permutation.as_slice())
2385                .map_err(|error| {
2386                    error.context(format!(
2387                        "einsum operand {operand_index} diagonal adjacency permutation"
2388                    ))
2389                })?
2390        };
2391
2392        let (repeated_flat_extent, diagonal_stride) =
2393            checked_diagonal_layout(extent, positions.len(), operand_index)?;
2394        let mut flattened_shape = vec![repeated_flat_extent];
2395        flattened_shape.extend_from_slice(&adjacent.dims()[positions.len()..]);
2396        let flattened = adjacent.reshape(flattened_shape).map_err(|error| {
2397            error.context(format!(
2398                "einsum operand {operand_index} repeated-axis flatten"
2399            ))
2400        })?;
2401
2402        let mut diagonal_indices = Vec::with_capacity(extent);
2403        for coordinate in 0..extent {
2404            let index = coordinate.checked_mul(diagonal_stride).ok_or_else(|| {
2405                candle_core::Error::msg(format!(
2406                    "einsum operand {operand_index} diagonal index overflows usize"
2407                ))
2408            })?;
2409            diagonal_indices.push(u32::try_from(index).map_err(|_| {
2410                candle_core::Error::msg(format!(
2411                    "einsum operand {operand_index} diagonal index {index} exceeds u32"
2412                ))
2413            })?);
2414        }
2415        let indices = Tensor::from_vec(diagonal_indices, extent, &device).map_err(|error| {
2416            error.context(format!(
2417                "einsum operand {operand_index} device-local diagonal indices"
2418            ))
2419        })?;
2420        let selected = flattened.index_select(&indices, 0).map_err(|error| {
2421            error.context(format!(
2422                "einsum operand {operand_index} differentiable diagonal selection"
2423            ))
2424        })?;
2425
2426        let result_order = std::iter::once(positions[0])
2427            .chain(other_positions.iter().copied())
2428            .collect::<Vec<_>>();
2429        let desired_order = (0..axes.len())
2430            .filter(|position| !positions[1..].contains(position))
2431            .collect::<Vec<_>>();
2432        let restoration = desired_order
2433            .iter()
2434            .map(|position| {
2435                result_order
2436                    .iter()
2437                    .position(|candidate| candidate == position)
2438                    .expect("diagonal result order contains every surviving axis")
2439            })
2440            .collect::<Vec<_>>();
2441        operand = if restoration.iter().copied().eq(0..restoration.len()) {
2442            selected
2443        } else {
2444            selected.permute(restoration.as_slice()).map_err(|error| {
2445                error.context(format!(
2446                    "einsum operand {operand_index} diagonal axis restoration"
2447                ))
2448            })?
2449        };
2450        axes = desired_order
2451            .iter()
2452            .map(|&position| axes[position])
2453            .collect();
2454    }
2455}
2456
2457fn checked_diagonal_layout(
2458    extent: usize,
2459    multiplicity: usize,
2460    operand_index: usize,
2461) -> Result<(usize, usize)> {
2462    let mut diagonal_stride = 0_usize;
2463    let mut power = 1_usize;
2464    for _ in 0..multiplicity {
2465        diagonal_stride = diagonal_stride.checked_add(power).ok_or_else(|| {
2466            candle_core::Error::msg(format!(
2467                "einsum operand {operand_index} diagonal stride overflows usize"
2468            ))
2469        })?;
2470        power = power.checked_mul(extent).ok_or_else(|| {
2471            candle_core::Error::msg(format!(
2472                "einsum operand {operand_index} repeated-axis extent product overflows usize"
2473            ))
2474        })?;
2475    }
2476    Ok((power, diagonal_stride))
2477}
2478
2479fn expand_axis_pattern<'a>(
2480    pattern: EinsumAxisPattern<'a>,
2481    ellipsis_rank: usize,
2482    implicit_operand_ellipsis: bool,
2483) -> Vec<ExpandedAxis<'a>> {
2484    let synthetic = (0..ellipsis_rank).map(ExpandedAxis::Ellipsis);
2485    match pattern.ellipsis_position {
2486        Some(position) => pattern.labels[..position]
2487            .iter()
2488            .copied()
2489            .map(ExpandedAxis::Named)
2490            .chain(synthetic)
2491            .chain(
2492                pattern.labels[position..]
2493                    .iter()
2494                    .copied()
2495                    .map(ExpandedAxis::Named),
2496            )
2497            .collect(),
2498        None if implicit_operand_ellipsis => synthetic
2499            .chain(pattern.labels.iter().copied().map(ExpandedAxis::Named))
2500            .collect(),
2501        None => pattern
2502            .labels
2503            .iter()
2504            .copied()
2505            .map(ExpandedAxis::Named)
2506            .collect(),
2507    }
2508}
2509
2510fn validate_expanded_output(
2511    inputs: &[&[ExpandedAxis<'_>]],
2512    output: &[ExpandedAxis<'_>],
2513) -> Result<()> {
2514    for axis in output {
2515        if !inputs.iter().any(|input| input.contains(axis)) {
2516            candle_core::bail!(
2517                "invalid ellipsis einsum plan: output axis `{}` does not occur in an input",
2518                axis.display_name()
2519            )
2520        }
2521    }
2522    Ok(())
2523}
2524
2525struct ExpandedBinaryPlan<'a> {
2526    batch: Vec<ExpandedAxis<'a>>,
2527    left_free: Vec<ExpandedAxis<'a>>,
2528    contracted: Vec<ExpandedAxis<'a>>,
2529    right_free: Vec<ExpandedAxis<'a>>,
2530    left_reductions: Vec<usize>,
2531    right_reductions: Vec<usize>,
2532    left_permutation: Vec<usize>,
2533    right_permutation: Vec<usize>,
2534    output_permutation: Vec<usize>,
2535}
2536
2537struct ExpandedBinaryGraph<'a> {
2538    plan: ExpandedBinaryPlan<'a>,
2539    work: u128,
2540    output_elements: u128,
2541    copy_bytes: u128,
2542    submissions: u128,
2543    output_layout: NaryLayoutEstimate,
2544}
2545
2546impl ExpandedBinaryGraph<'_> {
2547    fn cost(&self) -> NaryPairCost {
2548        NaryPairCost {
2549            flops: self.work,
2550            output_elements: self.output_elements,
2551            copy_bytes: self.copy_bytes,
2552            submissions: self.submissions,
2553        }
2554    }
2555}
2556
2557#[derive(Clone, Copy)]
2558enum BinaryOperandLayout<'a> {
2559    Contiguous,
2560    Strided(&'a [usize]),
2561    Unsupported,
2562}
2563
2564#[derive(Clone, Copy)]
2565struct BinaryGraphOperand<'axes, 'labels> {
2566    axes: &'axes [ExpandedAxis<'labels>],
2567    dims: &'axes [usize],
2568    layout: BinaryOperandLayout<'axes>,
2569}
2570
2571fn classify_expanded_binary_graph<'a>(
2572    left: BinaryGraphOperand<'_, 'a>,
2573    right: BinaryGraphOperand<'_, 'a>,
2574    output_axes: &[ExpandedAxis<'a>],
2575    element_bytes: usize,
2576) -> Result<ExpandedBinaryGraph<'a>> {
2577    let BinaryGraphOperand {
2578        axes: left_axes,
2579        dims: left_dims,
2580        layout: left_layout,
2581    } = left;
2582    let BinaryGraphOperand {
2583        axes: right_axes,
2584        dims: right_dims,
2585        layout: right_layout,
2586    } = right;
2587    if left_axes.len() != left_dims.len() || right_axes.len() != right_dims.len() {
2588        candle_core::bail!("binary graph axes and dimensions have different ranks")
2589    }
2590    let plan = classify_expanded_binary(left_axes, right_axes, output_axes);
2591    let extent = |axis: ExpandedAxis<'a>| -> Result<usize> {
2592        let left = left_axes
2593            .iter()
2594            .position(|candidate| *candidate == axis)
2595            .map(|index| left_dims[index]);
2596        let right = right_axes
2597            .iter()
2598            .position(|candidate| *candidate == axis)
2599            .map(|index| right_dims[index]);
2600        match (left, right) {
2601            (Some(left), Some(right)) => resolve_extent(&axis.display_name(), left, right),
2602            (Some(extent), None) | (None, Some(extent)) => Ok(extent),
2603            (None, None) => candle_core::bail!("binary graph output axis is absent from inputs"),
2604        }
2605    };
2606    let product = |axes: &[ExpandedAxis<'a>]| -> Result<u128> {
2607        let mut value = 1_u128;
2608        for &axis in axes {
2609            let axis = extent(axis)? as u128;
2610            if axis == 0 {
2611                return Ok(0);
2612            }
2613            value = value
2614                .checked_mul(axis)
2615                .ok_or_else(|| candle_core::Error::msg("n-ary dimension product overflows u128"))?;
2616        }
2617        Ok(value)
2618    };
2619    let output_elements = product(output_axes)?;
2620    let left_reduction_work = if plan.left_reductions.is_empty() {
2621        0
2622    } else {
2623        checked_nary_product(left_dims)?
2624    };
2625    let right_reduction_work = if plan.right_reductions.is_empty() {
2626        0
2627    } else {
2628        checked_nary_product(right_dims)?
2629    };
2630    let reduction_submissions = u128::from(!plan.left_reductions.is_empty())
2631        + u128::from(!plan.right_reductions.is_empty());
2632    let canonical_axes = plan
2633        .batch
2634        .iter()
2635        .chain(&plan.left_free)
2636        .chain(&plan.contracted)
2637        .chain(&plan.right_free)
2638        .copied()
2639        .collect::<Vec<_>>();
2640    let core_work = if plan.contracted.is_empty() {
2641        output_elements
2642    } else {
2643        product(&canonical_axes)?
2644    };
2645    let mut zero_core = false;
2646    if !plan.contracted.is_empty() {
2647        for &axis in &canonical_axes {
2648            zero_core |= extent(axis)? == 0;
2649        }
2650    }
2651
2652    let contiguous_strides = |dims: &[usize]| -> Result<Vec<usize>> {
2653        let mut strides = vec![0; dims.len()];
2654        let mut stride = 1usize;
2655        for (axis, &dimension) in dims.iter().enumerate().rev() {
2656            strides[axis] = stride;
2657            stride = stride.checked_mul(dimension).ok_or_else(|| {
2658                candle_core::Error::msg("binary graph stride estimate overflows usize")
2659            })?;
2660        }
2661        Ok(strides)
2662    };
2663    let materialized_bytes = |axes: &[ExpandedAxis<'a>],
2664                              dims: &[usize],
2665                              layout: BinaryOperandLayout<'_>,
2666                              reductions: &[usize],
2667                              permutation: &[usize],
2668                              group_lengths: &[usize]|
2669     -> Result<u128> {
2670        let mut broadcasted = false;
2671        let mut target_elements = 1_u128;
2672        let mut remaining_dims = Vec::with_capacity(axes.len() - reductions.len());
2673        for (index, (&axis, &dimension)) in axes.iter().zip(dims).enumerate() {
2674            if reductions.contains(&index) {
2675                continue;
2676            }
2677            let resolved = extent(axis)?;
2678            broadcasted |= resolved != dimension;
2679            if resolved == 0 {
2680                target_elements = 0;
2681            } else if target_elements != 0 {
2682                target_elements =
2683                    target_elements
2684                        .checked_mul(resolved as u128)
2685                        .ok_or_else(|| {
2686                            candle_core::Error::msg("binary graph copy estimate overflows u128")
2687                        })?;
2688            }
2689            remaining_dims.push(dimension);
2690        }
2691        let target_bytes = || {
2692            target_elements
2693                .checked_mul(element_bytes as u128)
2694                .ok_or_else(|| candle_core::Error::msg("binary graph copy estimate overflows u128"))
2695        };
2696        if broadcasted {
2697            return target_bytes();
2698        }
2699        let naturally_contiguous =
2700            !reductions.is_empty() || matches!(layout, BinaryOperandLayout::Contiguous);
2701        if naturally_contiguous && permutation.iter().copied().eq(0..permutation.len()) {
2702            return Ok(0);
2703        }
2704        if naturally_contiguous && group_lengths.iter().all(|&length| length <= 1) {
2705            return Ok(0);
2706        }
2707        let strides = if reductions.is_empty() {
2708            match layout {
2709                BinaryOperandLayout::Contiguous => contiguous_strides(dims)?,
2710                BinaryOperandLayout::Strided(strides) if strides.len() == dims.len() => {
2711                    strides.to_vec()
2712                }
2713                BinaryOperandLayout::Strided(_) => {
2714                    candle_core::bail!("binary graph dimensions and strides have different ranks")
2715                }
2716                BinaryOperandLayout::Unsupported => return target_bytes(),
2717            }
2718        } else {
2719            contiguous_strides(&remaining_dims)?
2720        };
2721        let canonical_dims = permutation
2722            .iter()
2723            .map(|&axis| remaining_dims[axis])
2724            .collect::<Vec<_>>();
2725        let canonical_strides = permutation
2726            .iter()
2727            .map(|&axis| strides[axis])
2728            .collect::<Vec<_>>();
2729        let nonempty_groups = group_lengths
2730            .iter()
2731            .copied()
2732            .filter(|&length| length != 0)
2733            .collect::<Vec<_>>();
2734        if nonempty_groups.is_empty()
2735            || crate::backend::plan_permute_compose_group_order(
2736                &canonical_dims,
2737                &canonical_strides,
2738                &(0..canonical_dims.len()).collect::<Vec<_>>(),
2739                &nonempty_groups,
2740            )?
2741            .is_some()
2742        {
2743            return Ok(0);
2744        }
2745        target_bytes()
2746    };
2747    let copy_bytes = if plan.contracted.is_empty() || zero_core {
2748        0
2749    } else {
2750        materialized_bytes(
2751            left_axes,
2752            left_dims,
2753            left_layout,
2754            &plan.left_reductions,
2755            &plan.left_permutation,
2756            &[
2757                plan.batch.len(),
2758                plan.left_free.len(),
2759                plan.contracted.len(),
2760            ],
2761        )?
2762        .checked_add(materialized_bytes(
2763            right_axes,
2764            right_dims,
2765            right_layout,
2766            &plan.right_reductions,
2767            &plan.right_permutation,
2768            &[
2769                plan.batch.len(),
2770                plan.contracted.len(),
2771                plan.right_free.len(),
2772            ],
2773        )?)
2774        .ok_or_else(|| candle_core::Error::msg("binary graph copy estimate overflows u128"))?
2775    };
2776    let work = left_reduction_work
2777        .checked_add(right_reduction_work)
2778        .and_then(|work| work.checked_add(if zero_core { 0 } else { core_work }))
2779        .ok_or_else(|| candle_core::Error::msg("binary graph work estimate overflows u128"))?;
2780    Ok(ExpandedBinaryGraph {
2781        plan,
2782        work,
2783        output_elements,
2784        copy_bytes,
2785        submissions: reduction_submissions + if zero_core { 2 } else { 1 },
2786        output_layout: if zero_core {
2787            NaryLayoutEstimate::Unsupported
2788        } else {
2789            NaryLayoutEstimate::Contiguous
2790        },
2791    })
2792}
2793
2794fn classify_expanded_binary<'a>(
2795    left: &[ExpandedAxis<'a>],
2796    right: &[ExpandedAxis<'a>],
2797    output: &[ExpandedAxis<'a>],
2798) -> ExpandedBinaryPlan<'a> {
2799    let mut all = Vec::new();
2800    for axis in left.iter().chain(right) {
2801        if !all.contains(axis) {
2802            all.push(*axis);
2803        }
2804    }
2805    let batch = all
2806        .iter()
2807        .copied()
2808        .filter(|axis| left.contains(axis) && right.contains(axis) && output.contains(axis))
2809        .collect::<Vec<_>>();
2810    let left_free = all
2811        .iter()
2812        .copied()
2813        .filter(|axis| left.contains(axis) && !right.contains(axis) && output.contains(axis))
2814        .collect::<Vec<_>>();
2815    let contracted = all
2816        .iter()
2817        .copied()
2818        .filter(|axis| left.contains(axis) && right.contains(axis) && !output.contains(axis))
2819        .collect::<Vec<_>>();
2820    let right_free = all
2821        .iter()
2822        .copied()
2823        .filter(|axis| !left.contains(axis) && right.contains(axis) && output.contains(axis))
2824        .collect::<Vec<_>>();
2825    let left_reductions = left
2826        .iter()
2827        .enumerate()
2828        .filter_map(|(index, axis)| {
2829            (!right.contains(axis) && !output.contains(axis)).then_some(index)
2830        })
2831        .collect();
2832    let right_reductions = right
2833        .iter()
2834        .enumerate()
2835        .filter_map(|(index, axis)| {
2836            (!left.contains(axis) && !output.contains(axis)).then_some(index)
2837        })
2838        .collect();
2839    let left_remaining = left
2840        .iter()
2841        .copied()
2842        .filter(|axis| right.contains(axis) || output.contains(axis))
2843        .collect::<Vec<_>>();
2844    let right_remaining = right
2845        .iter()
2846        .copied()
2847        .filter(|axis| left.contains(axis) || output.contains(axis))
2848        .collect::<Vec<_>>();
2849    let left_canonical = batch
2850        .iter()
2851        .chain(&left_free)
2852        .chain(&contracted)
2853        .copied()
2854        .collect::<Vec<_>>();
2855    let right_canonical = batch
2856        .iter()
2857        .chain(&contracted)
2858        .chain(&right_free)
2859        .copied()
2860        .collect::<Vec<_>>();
2861    let canonical_output = batch
2862        .iter()
2863        .chain(&left_free)
2864        .chain(&right_free)
2865        .copied()
2866        .collect::<Vec<_>>();
2867    ExpandedBinaryPlan {
2868        batch,
2869        left_free,
2870        contracted,
2871        right_free,
2872        left_reductions,
2873        right_reductions,
2874        left_permutation: dynamic_permutation(&left_remaining, &left_canonical),
2875        right_permutation: dynamic_permutation(&right_remaining, &right_canonical),
2876        output_permutation: dynamic_permutation(&canonical_output, output),
2877    }
2878}
2879
2880fn dynamic_permutation<T: Eq>(current: &[T], desired: &[T]) -> Vec<usize> {
2881    desired
2882        .iter()
2883        .map(|axis| {
2884            current
2885                .iter()
2886                .position(|candidate| candidate == axis)
2887                .expect("validated ellipsis classification")
2888        })
2889        .collect()
2890}
2891
2892fn materialize_broadcast_if_needed(
2893    tensor: &Tensor,
2894    shape: &[usize],
2895    context: &'static str,
2896) -> Result<Tensor> {
2897    if tensor.dims() == shape {
2898        Ok(tensor.clone())
2899    } else {
2900        tensor
2901            .broadcast_as(shape)
2902            .and_then(|tensor| tensor.contiguous())
2903            .map_err(|error| error.context(context))
2904    }
2905}
2906
2907fn pack_canonical_operand(
2908    tensor: &Tensor,
2909    packed_shape: &[usize],
2910    group_lengths: &[usize],
2911    context: &'static str,
2912) -> Result<Tensor> {
2913    if packed_shape.len() != group_lengths.len() {
2914        candle_core::bail!(
2915            "{context}: packed rank {} does not match group rank {}",
2916            packed_shape.len(),
2917            group_lengths.len()
2918        )
2919    }
2920
2921    let materialized;
2922    let tensor = if tensor
2923        .dims()
2924        .iter()
2925        .zip(tensor.stride())
2926        .any(|(&extent, &stride)| extent > 1 && stride == 0)
2927    {
2928        materialized = tensor
2929            .contiguous()
2930            .map_err(|error| error.context(context))?;
2931        &materialized
2932    } else {
2933        tensor
2934    };
2935
2936    let mut nonempty_shape = Vec::with_capacity(packed_shape.len());
2937    let mut nonempty_lengths = Vec::with_capacity(group_lengths.len());
2938    for (&extent, &length) in packed_shape.iter().zip(group_lengths) {
2939        if length == 0 {
2940            if extent != 1 {
2941                candle_core::bail!("{context}: an empty axis group must have extent one")
2942            }
2943        } else {
2944            nonempty_shape.push(extent);
2945            nonempty_lengths.push(length);
2946        }
2947    }
2948
2949    let mut output = if nonempty_lengths.is_empty() {
2950        tensor.clone()
2951    } else {
2952        let identity = (0..tensor.rank()).collect::<Vec<_>>();
2953        execute_tensor_permute_and_compose(tensor, &identity, &nonempty_shape, &nonempty_lengths)
2954            .map_err(|error| error.context(context))?
2955    };
2956    for (axis, &length) in group_lengths.iter().enumerate() {
2957        if length == 0 {
2958            output = output
2959                .unsqueeze(axis)
2960                .map_err(|error| error.context(context))?;
2961        }
2962    }
2963    Ok(output)
2964}
2965
2966/// Packs already-canonical operand groups through the production layout path.
2967#[cfg(feature = "benchmark-internals")]
2968#[doc(hidden)]
2969pub fn benchmark_pack_canonical_operand(
2970    tensor: &Tensor,
2971    packed_shape: &[usize],
2972    group_lengths: &[usize],
2973) -> Result<Tensor> {
2974    pack_canonical_operand(
2975        tensor,
2976        packed_shape,
2977        group_lengths,
2978        "benchmark canonical operand packing",
2979    )
2980}
2981
2982fn apply_output_permutation(output: Tensor, permutation: &[usize]) -> Result<Tensor> {
2983    if permutation.iter().copied().eq(0..permutation.len()) {
2984        Ok(output)
2985    } else {
2986        output
2987            .permute(permutation)
2988            .map_err(|error| error.context("einsum binary explicit output permutation"))
2989    }
2990}
2991
2992fn prepare_operand(
2993    operand: &Tensor,
2994    operand_index: usize,
2995    input_rank: usize,
2996    reduction_axes: &[usize],
2997    permutation: &[usize],
2998) -> Result<Tensor> {
2999    let mut reduced = vec![false; input_rank];
3000    for &axis in reduction_axes {
3001        if axis >= input_rank {
3002            candle_core::bail!(
3003                "invalid binary einsum plan: operand {operand_index} reduction axis {axis} is out of range for rank {input_rank}"
3004            )
3005        }
3006        if std::mem::replace(&mut reduced[axis], true) {
3007            candle_core::bail!(
3008                "invalid binary einsum plan: operand {operand_index} reduction axis {axis} occurs more than once"
3009            )
3010        }
3011    }
3012    let remaining_rank = input_rank - reduction_axes.len();
3013    validate_permutation(permutation, remaining_rank, "operand")?;
3014    let operand = if reduction_axes.is_empty() {
3015        operand.clone()
3016    } else {
3017        operand.sum(reduction_axes).map_err(|error| {
3018            error.context(format!("einsum operand {operand_index} pre-reduction"))
3019        })?
3020    };
3021    if permutation.iter().copied().eq(0..remaining_rank) {
3022        Ok(operand)
3023    } else {
3024        operand.permute(permutation).map_err(|error| {
3025            error.context(format!(
3026                "einsum operand {operand_index} canonical permutation"
3027            ))
3028        })
3029    }
3030}
3031
3032fn validate_permutation(permutation: &[usize], rank: usize, context: &str) -> Result<()> {
3033    if permutation.len() != rank {
3034        candle_core::bail!(
3035            "invalid binary einsum plan: {context} permutation has {} axes, expected {rank}",
3036            permutation.len(),
3037        )
3038    }
3039    let mut seen = vec![false; rank];
3040    for &axis in permutation {
3041        if axis >= rank || std::mem::replace(&mut seen[axis], true) {
3042            candle_core::bail!(
3043                "invalid binary einsum plan: {context} permutation is not a permutation of 0..{rank}"
3044            )
3045        }
3046    }
3047    Ok(())
3048}
3049
3050fn resolve_extent(label: &str, left: usize, right: usize) -> Result<usize> {
3051    match (left, right) {
3052        (left, right) if left == right => Ok(left),
3053        (1, right) => Ok(right),
3054        (left, 1) => Ok(left),
3055        _ => {
3056            candle_core::bail!("einsum label `{label}` cannot broadcast extents {left} and {right}")
3057        }
3058    }
3059}
3060
3061fn checked_product(dimensions: &[usize], category: &str) -> Result<usize> {
3062    dimensions.iter().try_fold(1_usize, |product, &extent| {
3063        product.checked_mul(extent).ok_or_else(|| {
3064            candle_core::Error::msg(format!(
3065                "einsum binary {category} flattened extent overflows usize"
3066            ))
3067        })
3068    })
3069}
3070
3071#[cfg(test)]
3072mod tests {
3073    use super::*;
3074    use candle_core::{DType, Device, Storage, Var};
3075
3076    fn storage_address(tensor: &Tensor) -> *const Storage {
3077        let (storage, _) = tensor.storage_and_layout();
3078        std::ptr::from_ref(&*storage)
3079    }
3080
3081    #[test]
3082    fn canonical_group_packing_uses_the_calibrated_cpu_rank_two_layout() -> Result<()> {
3083        let source = Tensor::arange(0f32, 24., &Device::Cpu)?.reshape((4, 2, 3))?;
3084        let canonical = source.permute((1, 2, 0))?;
3085        assert!(!canonical.is_contiguous());
3086        let historical = canonical.reshape((1, 6, 4))?;
3087        assert_ne!(storage_address(&historical), storage_address(&source));
3088
3089        let packed =
3090            pack_canonical_operand(&canonical, &[1, 6, 4], &[0, 2, 1], "test canonical operand")?;
3091        assert_eq!(packed.dims(), [1, 6, 4]);
3092        assert_ne!(storage_address(&packed), storage_address(&source));
3093        assert!(packed.is_contiguous());
3094        assert_eq!(
3095            packed.flatten_all()?.to_vec1::<f32>()?,
3096            historical.flatten_all()?.to_vec1::<f32>()?
3097        );
3098        Ok(())
3099    }
3100
3101    #[test]
3102    fn canonical_group_packing_preserves_broadcast_and_reduction_order() -> Result<()> {
3103        let left = Tensor::arange(1f32, 13., &Device::Cpu)?
3104            .reshape((2, 1, 2, 3))?
3105            .sum(0)?
3106            .broadcast_as((2, 2, 3))?;
3107        let right = Tensor::ones((2, 3, 4, 2), DType::F32, &Device::Cpu)?.sum(3)?;
3108        for (tensor, shape) in [(&left, [2, 2, 3]), (&right, [2, 3, 4])] {
3109            let historical = tensor.reshape(&shape)?;
3110            let packed = pack_canonical_operand(tensor, &shape, &[1, 1, 1], "test packing")?;
3111            assert_eq!(
3112                packed.flatten_all()?.to_vec1::<f32>()?,
3113                historical.flatten_all()?.to_vec1::<f32>()?,
3114                "dims={:?} strides={:?}",
3115                tensor.dims(),
3116                tensor.stride()
3117            );
3118        }
3119        let historical = left
3120            .reshape((2, 2, 3))?
3121            .matmul(&right.reshape((2, 3, 4))?)?;
3122        let packed_left = pack_canonical_operand(&left, &[2, 2, 3], &[1, 1, 1], "test left")?;
3123        let packed_right = pack_canonical_operand(&right, &[2, 3, 4], &[1, 1, 1], "test right")?;
3124        assert!(
3125            packed_left.is_contiguous(),
3126            "left strides {:?}",
3127            packed_left.stride()
3128        );
3129        assert!(
3130            packed_right.is_contiguous(),
3131            "right strides {:?}",
3132            packed_right.stride()
3133        );
3134        let packed = packed_left.matmul(&packed_right)?;
3135        assert_eq!(
3136            packed.flatten_all()?.to_vec1::<f32>()?,
3137            historical.flatten_all()?.to_vec1::<f32>()?
3138        );
3139        Ok(())
3140    }
3141
3142    #[test]
3143    fn rejects_invalid_runtime_specs_without_panicking() -> Result<()> {
3144        let input = Tensor::zeros((2, 3), DType::F32, &Device::Cpu)?;
3145
3146        assert!(execute_unary_einsum(&input, UnaryEinsumSpec::new(3, 2, &[0, 1, 2])).is_err());
3147        assert!(execute_unary_einsum(&input, UnaryEinsumSpec::new(2, 3, &[0, 1])).is_err());
3148        assert!(execute_unary_einsum(&input, UnaryEinsumSpec::new(2, 2, &[0])).is_err());
3149        assert!(execute_unary_einsum(&input, UnaryEinsumSpec::new(2, 2, &[0, 2])).is_err());
3150        assert!(execute_unary_einsum(&input, UnaryEinsumSpec::new(2, 2, &[0, 0])).is_err());
3151
3152        Ok(())
3153    }
3154
3155    #[test]
3156    fn rejects_invalid_binary_specs_and_checked_shape_overflow() -> Result<()> {
3157        let left = Tensor::zeros((2, 3), DType::F32, &Device::Cpu)?;
3158        let right = Tensor::zeros((3, 4), DType::F32, &Device::Cpu)?;
3159        let valid = BinaryEinsumSpec::new(
3160            [2, 2],
3161            [&[], &[]],
3162            [&[0, 1], &[0, 1]],
3163            0,
3164            1,
3165            1,
3166            1,
3167            &[],
3168            &["inner"],
3169            &[0, 1],
3170        );
3171        assert_eq!(execute_binary_einsum(&left, &right, valid)?.dims(), &[2, 4]);
3172
3173        let invalid = BinaryEinsumSpec::new(
3174            [2, 2],
3175            [&[], &[]],
3176            [&[0, 0], &[0, 1]],
3177            0,
3178            1,
3179            1,
3180            1,
3181            &[],
3182            &["inner"],
3183            &[0, 1],
3184        );
3185        assert!(execute_binary_einsum(&left, &right, invalid).is_err());
3186        assert!(checked_product(&[usize::MAX, 2], "test").is_err());
3187        Ok(())
3188    }
3189
3190    #[test]
3191    fn ellipsis_runtime_validates_rank_and_normalizes_right_alignment() -> Result<()> {
3192        let vector = Tensor::zeros(1, DType::F32, &Device::Cpu)?;
3193        let invalid_operands = [EinsumAxisPattern::new(&["row", "inner"], Some(0))];
3194        let invalid =
3195            EllipsisEinsumSpec::new(&invalid_operands, EinsumAxisPattern::new(&["row"], Some(0)));
3196        assert!(execute_unary_ellipsis_einsum(&vector, invalid).is_err());
3197
3198        let left = Tensor::ones((2, 1, 2, 3), DType::F32, &Device::Cpu)?;
3199        let right = Tensor::ones((4, 3, 2), DType::F32, &Device::Cpu)?;
3200        let valid_operands = [
3201            EinsumAxisPattern::new(&["row", "inner"], Some(0)),
3202            EinsumAxisPattern::new(&["inner", "column"], Some(0)),
3203        ];
3204        let valid = EllipsisEinsumSpec::new(
3205            &valid_operands,
3206            EinsumAxisPattern::new(&["row", "column"], Some(1)),
3207        );
3208        assert_eq!(
3209            execute_binary_ellipsis_einsum(&left, &right, valid)?.dims(),
3210            &[2, 2, 4, 2]
3211        );
3212        Ok(())
3213    }
3214
3215    #[test]
3216    fn diagonal_layout_arithmetic_is_checked() {
3217        assert!(checked_diagonal_layout(usize::MAX, 2, 0).is_err());
3218        assert_eq!(checked_diagonal_layout(3, 3, 0).unwrap(), (27, 13));
3219        assert_eq!(checked_diagonal_layout(0, 3, 0).unwrap(), (0, 1));
3220    }
3221
3222    fn named_axes(labels: &[&'static str]) -> Vec<ExpandedAxis<'static>> {
3223        labels.iter().copied().map(ExpandedAxis::Named).collect()
3224    }
3225
3226    #[test]
3227    fn diagonal_plan_selects_only_when_sequential_flatten_would_copy() -> Result<()> {
3228        assert_eq!(
3229            plan_repeated_axis_lowering(&[3, 3], &named_axes(&["i", "i"]), true, 0)?,
3230            RepeatedAxisLoweringPlan::Sequential
3231        );
3232        assert_eq!(
3233            plan_repeated_axis_lowering(&[2, 3, 3], &named_axes(&["batch", "i", "i"]), true, 0,)?,
3234            RepeatedAxisLoweringPlan::OriginalFlatGather {
3235                output_shape: vec![2, 3],
3236                offsets: vec![0, 4, 8, 9, 13, 17],
3237            }
3238        );
3239        assert_eq!(
3240            plan_repeated_axis_lowering(
3241                &[2, 3, 2, 3],
3242                &named_axes(&["i", "j", "i", "j"]),
3243                true,
3244                0,
3245            )?,
3246            RepeatedAxisLoweringPlan::OriginalFlatGather {
3247                output_shape: vec![2, 3],
3248                offsets: vec![0, 7, 14, 21, 28, 35],
3249            }
3250        );
3251        Ok(())
3252    }
3253
3254    #[test]
3255    fn diagonal_plan_preserves_fallbacks_and_checks_zero_before_offsets() -> Result<()> {
3256        assert_eq!(
3257            plan_repeated_axis_lowering(
3258                &[2, 3, 2, 3],
3259                &named_axes(&["i", "j", "i", "j"]),
3260                false,
3261                0,
3262            )?,
3263            RepeatedAxisLoweringPlan::Sequential
3264        );
3265        assert_eq!(
3266            plan_repeated_axis_lowering(
3267                &[2, 65_536, 65_536],
3268                &named_axes(&["batch", "i", "i"]),
3269                true,
3270                0,
3271            )?,
3272            RepeatedAxisLoweringPlan::Sequential
3273        );
3274        assert_eq!(
3275            plan_repeated_axis_lowering(
3276                &[256, 256, 256, 256],
3277                &named_axes(&["i", "j", "i", "j"]),
3278                true,
3279                0,
3280            )?,
3281            RepeatedAxisLoweringPlan::Sequential,
3282            "u32::MAX is Candle's zero-fill index sentinel"
3283        );
3284        assert_eq!(
3285            plan_repeated_axis_lowering(&[2, 0, 0], &named_axes(&["batch", "i", "i"]), true, 0,)?,
3286            RepeatedAxisLoweringPlan::OriginalFlatGather {
3287                output_shape: vec![2, 0],
3288                offsets: vec![],
3289            }
3290        );
3291        Ok(())
3292    }
3293
3294    #[test]
3295    fn diagonal_plan_validates_every_repeated_extent_before_arithmetic() {
3296        let error = plan_repeated_axis_lowering(
3297            &[usize::MAX, usize::MAX, 2, 3],
3298            &named_axes(&["i", "i", "j", "j"]),
3299            true,
3300            0,
3301        )
3302        .expect_err("the later unequal group must be rejected first");
3303        assert!(error.to_string().contains("repeated label `j`"));
3304        assert!(error.to_string().contains("unequal extents 2 and 3"));
3305    }
3306
3307    #[test]
3308    fn nary_planner_avoids_a_pathological_left_to_right_intermediate() -> Result<()> {
3309        let operands = vec![
3310            PlannedOperand::new_for_test((100, 2), &["a", "b"], 0)?,
3311            PlannedOperand::new_for_test((2, 100), &["b", "c"], 1)?,
3312            PlannedOperand::new_for_test((100, 2), &["c", "d"], 2)?,
3313        ];
3314        let output = [ExpandedAxis::Named("a"), ExpandedAxis::Named("d")];
3315        let left_to_right = estimate_pair(&operands, 0, 1, &output)?;
3316        let selected = select_nary_pair(&operands, &output)?;
3317        assert_eq!((selected.left, selected.right), (1, 2));
3318        assert_eq!(left_to_right.output_elements, 10_000);
3319        assert_eq!(selected.output_elements, 4);
3320        Ok(())
3321    }
3322
3323    #[test]
3324    fn nary_planner_breaks_equal_cost_ties_by_operand_order() -> Result<()> {
3325        let operands = vec![
3326            PlannedOperand::new_for_test((2, 2), &["a", "b"], 0)?,
3327            PlannedOperand::new_for_test((2, 2), &["b", "c"], 1)?,
3328            PlannedOperand::new_for_test((2, 2), &["c", "d"], 2)?,
3329        ];
3330        let output = [ExpandedAxis::Named("a"), ExpandedAxis::Named("d")];
3331        let selected = select_nary_pair(&operands, &output)?;
3332        assert_eq!((selected.left, selected.right), (0, 1));
3333        assert_eq!((selected.output_elements, selected.flops), (4, 8));
3334        Ok(())
3335    }
3336
3337    #[test]
3338    fn nary_cost_estimate_overflow_is_checked() {
3339        assert!(checked_nary_product(&[usize::MAX; 5]).is_err());
3340        assert_eq!(
3341            checked_nary_product(&[usize::MAX, usize::MAX, 0]).unwrap(),
3342            0
3343        );
3344    }
3345
3346    #[test]
3347    fn nary_pair_model_counts_local_reduction_and_core_submission() -> Result<()> {
3348        let state = vec![
3349            planner_meta(
3350                0,
3351                &[("m", 2), ("k", 3), ("discard", 5)],
3352                NaryLayoutEstimate::Contiguous,
3353            ),
3354            planner_meta(1, &[("k", 3), ("n", 7)], NaryLayoutEstimate::Contiguous),
3355        ];
3356        let output = [ExpandedAxis::Named("m"), ExpandedAxis::Named("n")];
3357        let order = named_axes(&["m", "k", "discard", "n"]);
3358        let (estimate, intermediate) = model_pair_details(&state, 0, 1, &output, &order)?;
3359
3360        assert_eq!(estimate.submissions, 2, "one reduction plus one GEMM");
3361        assert_eq!(estimate.flops, 30 + 42, "reduction work plus GEMM work");
3362        assert_eq!(estimate.output_elements, 14);
3363        assert_eq!(estimate.copy_bytes, 0);
3364        assert_eq!(intermediate.layout, NaryLayoutEstimate::Contiguous);
3365        Ok(())
3366    }
3367
3368    #[test]
3369    fn nary_pair_model_distinguishes_recoverable_and_copying_strides() -> Result<()> {
3370        let pair = |layout| {
3371            vec![
3372                planner_meta(0, &[("a", 2), ("b", 3), ("k", 5)], layout),
3373                planner_meta(1, &[("k", 5), ("n", 7)], NaryLayoutEstimate::Contiguous),
3374            ]
3375        };
3376        let output = named_axes(&["a", "b", "n"]);
3377        let order = named_axes(&["a", "b", "k", "n"]);
3378
3379        let (recovered, _) = model_pair_details(
3380            &pair(NaryLayoutEstimate::Strided(vec![3, 1, 6])),
3381            0,
3382            1,
3383            &output,
3384            &order,
3385        )?;
3386        let (copied, _) = model_pair_details(
3387            &pair(NaryLayoutEstimate::Strided(vec![15, 1, 3])),
3388            0,
3389            1,
3390            &output,
3391            &order,
3392        )?;
3393
3394        assert_eq!(recovered.copy_bytes, 0);
3395        assert_eq!(copied.copy_bytes, 2 * 3 * 5 * 4);
3396        Ok(())
3397    }
3398
3399    #[test]
3400    fn nary_pair_model_separates_multiply_gemm_broadcast_and_zero_graphs() -> Result<()> {
3401        let multiply = vec![
3402            planner_meta(
3403                0,
3404                &[("i", 4), ("discard", 3)],
3405                NaryLayoutEstimate::Contiguous,
3406            ),
3407            planner_meta(1, &[("j", 5)], NaryLayoutEstimate::Contiguous),
3408        ];
3409        let (multiply, multiply_output) = model_pair_details(
3410            &multiply,
3411            0,
3412            1,
3413            &named_axes(&["i", "j"]),
3414            &named_axes(&["i", "discard", "j"]),
3415        )?;
3416        assert_eq!(multiply.submissions, 2);
3417        assert_eq!(multiply.flops, 12 + 20);
3418        assert_eq!(multiply.copy_bytes, 0);
3419        assert_eq!(multiply_output.layout, NaryLayoutEstimate::Contiguous);
3420
3421        let broadcast = vec![
3422            planner_meta(
3423                0,
3424                &[("batch", 1), ("m", 4), ("k", 8)],
3425                NaryLayoutEstimate::Contiguous,
3426            ),
3427            planner_meta(
3428                1,
3429                &[("batch", 16), ("k", 8), ("n", 2)],
3430                NaryLayoutEstimate::Contiguous,
3431            ),
3432        ];
3433        let output = named_axes(&["batch", "m", "n"]);
3434        let order = named_axes(&["batch", "m", "k", "n"]);
3435        let (broadcast, broadcast_output) = model_pair_details(&broadcast, 0, 1, &output, &order)?;
3436        assert_eq!(broadcast.submissions, 1);
3437        assert_eq!(broadcast.copy_bytes, 16 * 4 * 8 * 4);
3438        assert_eq!(broadcast_output.layout, NaryLayoutEstimate::Contiguous);
3439
3440        let zero = vec![
3441            planner_meta(0, &[("m", 4), ("k", 0)], NaryLayoutEstimate::Contiguous),
3442            planner_meta(1, &[("k", 0), ("n", 2)], NaryLayoutEstimate::Contiguous),
3443        ];
3444        let (zero, zero_output) = model_pair_details(
3445            &zero,
3446            0,
3447            1,
3448            &named_axes(&["m", "n"]),
3449            &named_axes(&["m", "k", "n"]),
3450        )?;
3451        assert_eq!(zero.submissions, 2, "empty cat plus one zero reduction");
3452        assert_eq!(zero.flops, 0);
3453        assert_eq!(zero.copy_bytes, 0);
3454        assert_eq!(zero_output.layout, NaryLayoutEstimate::Unsupported);
3455        Ok(())
3456    }
3457
3458    fn planner_meta(
3459        ordinal: usize,
3460        axes: &[(&'static str, usize)],
3461        layout: NaryLayoutEstimate,
3462    ) -> NaryPlannerMetadata<'static> {
3463        NaryPlannerMetadata::new_for_test(ordinal, axes, layout)
3464    }
3465
3466    fn matrix_chain_metadata(
3467        dimensions: [usize; 5],
3468        batch: Option<usize>,
3469    ) -> Vec<NaryPlannerMetadata<'static>> {
3470        let [a, b, c, d, e] = dimensions;
3471        let operand = |ordinal, axes: [(&'static str, usize); 2]| {
3472            let mut metadata = batch
3473                .map(|extent| vec![("batch", if ordinal == 0 { 1 } else { extent })])
3474                .unwrap_or_default();
3475            metadata.extend(axes);
3476            planner_meta(ordinal, &metadata, NaryLayoutEstimate::Contiguous)
3477        };
3478        vec![
3479            operand(0, [("a", a), ("b", b)]),
3480            operand(1, [("b", b), ("c", c)]),
3481            operand(2, [("c", c), ("d", d)]),
3482            operand(3, [("d", d), ("e", e)]),
3483        ]
3484    }
3485
3486    #[test]
3487    fn layout_aware_selection_freezes_counterexamples_and_every_boundary() {
3488        let balanced = matrix_chain_metadata([128, 8, 1, 8, 128], None);
3489        let selected = select_layout_aware_plan_for_test(&balanced, &["a", "e"], DType::F32, true);
3490        assert!(matches!(
3491            selected,
3492            NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration)
3493        ));
3494
3495        let broadcast = matrix_chain_metadata([32, 32, 15, 5, 10], Some(32));
3496        assert!(matches!(
3497            select_layout_aware_plan_for_test(&broadcast, &["batch", "a", "e"], DType::F32, true),
3498            NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration)
3499        ));
3500
3501        let linear = matrix_chain_metadata([30, 35, 15, 5, 10], None);
3502        assert!(matches!(
3503            select_layout_aware_plan_for_test(&linear, &["a", "e"], DType::F32, true),
3504            NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration)
3505        ));
3506        let just_below = (0..3)
3507            .map(|ordinal| planner_meta(ordinal, &[("k", 49_999)], NaryLayoutEstimate::Contiguous))
3508            .collect::<Vec<_>>();
3509        assert!(matches!(
3510            select_layout_aware_plan_for_test(&just_below, &[], DType::F32, true),
3511            NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration)
3512        ));
3513        let at_threshold = (0..3)
3514            .map(|ordinal| planner_meta(ordinal, &[("k", 50_000)], NaryLayoutEstimate::Contiguous))
3515            .collect::<Vec<_>>();
3516        assert!(matches!(
3517            select_layout_aware_plan_for_test(&at_threshold, &[], DType::F32, true),
3518            NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration)
3519        ));
3520        assert!(matches!(
3521            select_layout_aware_plan_for_test(&balanced, &["a", "e"], DType::F64, true),
3522            NaryPlannerDecision::Greedy(NaryGreedyReason::DType)
3523        ));
3524        assert!(matches!(
3525            select_layout_aware_plan_for_test(&balanced, &["a", "e"], DType::F32, false),
3526            NaryPlannerDecision::Greedy(NaryGreedyReason::Backend)
3527        ));
3528        let mut unsupported = balanced.clone();
3529        unsupported[0] = planner_meta(0, &[("a", 128), ("b", 8)], NaryLayoutEstimate::Unsupported);
3530        assert!(matches!(
3531            select_layout_aware_plan_for_test(&unsupported, &["a", "e"], DType::F32, true),
3532            NaryPlannerDecision::Greedy(NaryGreedyReason::UnsupportedLayout)
3533        ));
3534        let five = (0..5)
3535            .map(|ordinal| planner_meta(ordinal, &[("i", 128)], NaryLayoutEstimate::Contiguous))
3536            .collect::<Vec<_>>();
3537        assert!(matches!(
3538            select_layout_aware_plan_for_test(&five, &["i"], DType::F32, true),
3539            NaryPlannerDecision::Greedy(NaryGreedyReason::Arity)
3540        ));
3541    }
3542
3543    #[test]
3544    fn exact_search_is_structurally_bounded_zero_first_checked_and_fully_stable() {
3545        let equal = (0..4)
3546            .map(|ordinal| planner_meta(ordinal, &[("i", 128)], NaryLayoutEstimate::Contiguous))
3547            .collect::<Vec<_>>();
3548        let first = plan_layout_exact_for_test(&equal, &["i"]).unwrap();
3549        let second = plan_layout_exact_for_test(&equal, &["i"]).unwrap();
3550        assert_eq!(first, second);
3551        assert_eq!(
3552            first
3553                .steps
3554                .iter()
3555                .map(|step| step.members)
3556                .collect::<Vec<_>>(),
3557            [(1, 2), (3, 4), (7, 8)]
3558        );
3559
3560        let five = (0..5)
3561            .map(|ordinal| planner_meta(ordinal, &[("i", 128)], NaryLayoutEstimate::Contiguous))
3562            .collect::<Vec<_>>();
3563        assert!(plan_layout_exact_for_test(&five, &["i"]).is_err());
3564
3565        let late_zero = vec![
3566            planner_meta(
3567                0,
3568                &[("huge_a", usize::MAX), ("huge_b", usize::MAX)],
3569                NaryLayoutEstimate::Contiguous,
3570            ),
3571            planner_meta(
3572                1,
3573                &[("huge_c", usize::MAX), ("zero", 0)],
3574                NaryLayoutEstimate::Contiguous,
3575            ),
3576            planner_meta(2, &[], NaryLayoutEstimate::Contiguous),
3577        ];
3578        let decision = select_layout_aware_plan_for_test(&late_zero, &[], DType::F32, true);
3579        assert!(matches!(
3580            decision,
3581            NaryPlannerDecision::Greedy(NaryGreedyReason::BelowFlopThreshold)
3582        ));
3583
3584        let overflow = vec![
3585            planner_meta(
3586                0,
3587                &[("a", usize::MAX), ("b", usize::MAX)],
3588                NaryLayoutEstimate::Contiguous,
3589            ),
3590            planner_meta(
3591                1,
3592                &[("b", usize::MAX), ("c", usize::MAX)],
3593                NaryLayoutEstimate::Contiguous,
3594            ),
3595            planner_meta(2, &[("c", 1)], NaryLayoutEstimate::Contiguous),
3596        ];
3597        assert!(matches!(
3598            select_layout_aware_plan_for_test(&overflow, &["a"], DType::F32, true),
3599            NaryPlannerDecision::Greedy(NaryGreedyReason::Calibration)
3600        ));
3601    }
3602
3603    fn nary_spec<'a>(
3604        operands: &'a [EinsumAxisPattern<'a>],
3605        output: EinsumAxisPattern<'a>,
3606    ) -> EllipsisEinsumSpec<'a> {
3607        EllipsisEinsumSpec::new(operands, output)
3608    }
3609
3610    fn assert_mixed_close(left: &Tensor, right: &Tensor) -> Result<()> {
3611        assert_eq!(left.dims(), right.dims());
3612        let left = left.flatten_all()?.to_vec1::<f32>()?;
3613        let right = right.flatten_all()?.to_vec1::<f32>()?;
3614        for (&left, &right) in left.iter().zip(&right) {
3615            assert!((left - right).abs() <= 0.002 * right.abs().max(1.));
3616        }
3617        Ok(())
3618    }
3619
3620    #[test]
3621    fn broadcast_selected_execution_matches_greedy_forward_and_every_gradient() -> Result<()> {
3622        let device = Device::Cpu;
3623        let patterns = [
3624            EinsumAxisPattern::new(&["batch", "a", "b"], None),
3625            EinsumAxisPattern::new(&["batch", "b", "c"], None),
3626            EinsumAxisPattern::new(&["batch", "c", "d"], None),
3627            EinsumAxisPattern::new(&["batch", "d", "e"], None),
3628        ];
3629        let output = EinsumAxisPattern::new(&["batch", "a", "e"], None);
3630        let shapes = [[1, 32, 32], [32, 32, 15], [32, 15, 5], [32, 5, 10]];
3631        let values = shapes
3632            .iter()
3633            .enumerate()
3634            .map(|(ordinal, shape)| {
3635                let elements = shape.iter().product::<usize>();
3636                let values = (0..elements)
3637                    .map(|index| ((index + ordinal * 7) % 23) as f32 / 23.)
3638                    .collect::<Vec<_>>();
3639                Tensor::from_vec(values, shape.as_slice(), &device)
3640            })
3641            .collect::<Result<Vec<_>>>()?;
3642        let selected_vars = values
3643            .iter()
3644            .map(Var::from_tensor)
3645            .collect::<Result<Vec<_>>>()?;
3646        let greedy_vars = values
3647            .iter()
3648            .map(Var::from_tensor)
3649            .collect::<Result<Vec<_>>>()?;
3650        let selected_refs = selected_vars.iter().map(Var::as_tensor).collect::<Vec<_>>();
3651        let greedy_refs = greedy_vars.iter().map(Var::as_tensor).collect::<Vec<_>>();
3652        let (selected, trace) = execute_nary_einsum_for_test(
3653            &selected_refs,
3654            nary_spec(&patterns, output),
3655            NaryExecutionStrategy::Selected,
3656        )?;
3657        let (greedy, greedy_trace) = execute_nary_einsum_for_test(
3658            &greedy_refs,
3659            nary_spec(&patterns, output),
3660            NaryExecutionStrategy::StreamingGreedy,
3661        )?;
3662        assert_mixed_close(&selected, &greedy)?;
3663        assert!(!trace.used_exact);
3664        assert_eq!(trace.member_sequence, greedy_trace.member_sequence);
3665
3666        let selected_gradients = selected.sum_all()?.backward()?;
3667        let greedy_gradients = greedy.sum_all()?.backward()?;
3668        for (selected, greedy) in selected_vars.iter().zip(&greedy_vars) {
3669            assert_mixed_close(
3670                selected_gradients.get(selected.as_tensor()).unwrap(),
3671                greedy_gradients.get(greedy.as_tensor()).unwrap(),
3672            )?;
3673        }
3674        Ok(())
3675    }
3676
3677    #[test]
3678    fn selected_execution_matches_frozen_greedy_forward_gradients_and_layout_trace() -> Result<()> {
3679        clear_nary_plan_cache_for_test();
3680        let device = Device::Cpu;
3681        let patterns = [
3682            EinsumAxisPattern::new(&["a", "b"], None),
3683            EinsumAxisPattern::new(&["b", "c"], None),
3684            EinsumAxisPattern::new(&["c", "d"], None),
3685            EinsumAxisPattern::new(&["d", "e"], None),
3686        ];
3687        let output = EinsumAxisPattern::new(&["a", "e"], None);
3688        let values = [
3689            Tensor::arange(0f32, (128 * 8) as f32, &device)?.reshape((128, 8))?,
3690            Tensor::arange(0f32, 8., &device)?.reshape((8, 1))?,
3691            Tensor::arange(0f32, 8., &device)?.reshape((1, 8))?,
3692            Tensor::arange(0f32, (8 * 128) as f32, &device)?.reshape((8, 128))?,
3693        ];
3694        let selected_vars = values
3695            .iter()
3696            .map(Var::from_tensor)
3697            .collect::<Result<Vec<_>>>()?;
3698        let greedy_vars = values
3699            .iter()
3700            .map(Var::from_tensor)
3701            .collect::<Result<Vec<_>>>()?;
3702        let selected_refs = selected_vars
3703            .iter()
3704            .map(|var| var.as_tensor())
3705            .collect::<Vec<_>>();
3706        let greedy_refs = greedy_vars
3707            .iter()
3708            .map(|var| var.as_tensor())
3709            .collect::<Vec<_>>();
3710        let (selected, trace) = execute_nary_einsum_for_test(
3711            &selected_refs,
3712            nary_spec(&patterns, output),
3713            NaryExecutionStrategy::Selected,
3714        )?;
3715        let (_, cached_trace) = execute_nary_einsum_for_test(
3716            &selected_refs,
3717            nary_spec(&patterns, output),
3718            NaryExecutionStrategy::Selected,
3719        )?;
3720        let (greedy, greedy_trace) = execute_nary_einsum_for_test(
3721            &greedy_refs,
3722            nary_spec(&patterns, output),
3723            NaryExecutionStrategy::StreamingGreedy,
3724        )?;
3725        assert_mixed_close(&selected, &greedy)?;
3726        assert!(!trace.used_exact);
3727        assert!(!trace.used_cached_greedy);
3728        assert!(cached_trace.used_cached_greedy);
3729        assert_eq!(cached_trace.member_sequence, trace.member_sequence);
3730        assert!(!greedy_trace.used_exact);
3731        assert_eq!(trace.member_sequence, greedy_trace.member_sequence);
3732        assert_eq!(trace.final_permutations, 1);
3733        assert!(trace.intermediates.iter().all(|step| step.canonical));
3734        assert!(
3735            trace
3736                .intermediates
3737                .iter()
3738                .all(|step| step.estimated == step.execution_graph)
3739        );
3740        assert!(
3741            trace
3742                .intermediates
3743                .iter()
3744                .all(|step| { step.output_layout == NaryLayoutEstimate::Contiguous })
3745        );
3746
3747        let selected_gradients = selected.sum_all()?.backward()?;
3748        let greedy_gradients = greedy.sum_all()?.backward()?;
3749        for (selected, greedy) in selected_vars.iter().zip(&greedy_vars) {
3750            assert_mixed_close(
3751                selected_gradients.get(selected.as_tensor()).unwrap(),
3752                greedy_gradients.get(greedy.as_tensor()).unwrap(),
3753            )?;
3754        }
3755        Ok(())
3756    }
3757}