Skip to main content

tenferro_einsum/
eager_ad.rs

1//! EagerTensor einsum extension API.
2
3use std::collections::hash_map::DefaultHasher;
4use std::error::Error as StdError;
5use std::hash::{Hash, Hasher};
6use std::mem::size_of;
7use std::sync::Arc;
8
9use computegraph::compile::{compile, CompiledProgram, Instruction};
10use computegraph::graph::GraphBuilder;
11use computegraph::materialize::materialize_merge;
12use computegraph::resolve::resolve;
13use computegraph::types::{ValueKey, ValueRef};
14use tenferro_ad::extension::{
15    adopt_untracked_eager_value, apply_eager_with_targeted_extension_session,
16    EagerExtensionBackendKind, EagerExtensionTarget,
17};
18use tenferro_ad::{EagerRuntime, EagerTensor};
19use tenferro_cpu::CpuBackend;
20#[cfg(feature = "cuda")]
21use tenferro_gpu::cuda::CudaBackend;
22#[cfg(feature = "webgpu")]
23use tenferro_gpu::webgpu::WebGpuBackend;
24use tenferro_ops::dim_expr::DimExpr;
25use tenferro_ops::input_key::TensorInputKey;
26use tenferro_ops::std_tensor_op::StdTensorOp;
27use tenferro_runtime::{ErrorPhase, ExtensionCacheKey, ExtensionModule};
28use tenferro_tensor::{ErrorKind, ShapeMismatch, Tensor, ValidationError, ValidationKind};
29
30use crate::binary_dot::{try_build_exact_output_binary_dot_plan, BinaryDotOperandOrder};
31use crate::builder::build_einsum_graph;
32use crate::cache::{
33    saturating_sum, vec_retained_bytes, EINSUM_EAGER_EXPANDED_PROGRAMS_CACHE,
34    EINSUM_EXTENSION_FAMILY_ID,
35};
36use crate::ellipsis::resolve_einsum_notation;
37use crate::extension::EinsumExtensionOp;
38use crate::optimize::{
39    default_auto_options, hash_einsum_plan_spec, plan_specs_equal, resolve_plan_spec,
40    EinsumPlanSpec,
41};
42use crate::{
43    parse_einsum_notation, EinsumNotation, EinsumSubscripts, Error, Result, Subscripts,
44    TensorDotAxes,
45};
46
47/// Eager einsum extension methods for slices or arrays of [`EagerTensor`] refs.
48pub trait EagerEinsumExt {
49    /// Execute an einsum from string notation.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::InvalidSubscripts`] for malformed notation,
54    /// [`Error::Validation`] for rank/shape/dtype mismatches, or
55    /// [`Error::Planning`] / [`Error::Runtime`] for contraction planning and
56    /// execution failures.
57    fn einsum(&self, subscripts: &str) -> Result<EagerTensor>;
58
59    /// Execute an einsum from rank-unresolved notation.
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use tenferro_einsum::{EinsumAxis, EinsumNotation};
65    /// let notation = EinsumNotation::new(&[&[EinsumAxis::Ellipsis]], &[]);
66    /// assert_eq!(notation.input_count(), 1);
67    /// ```
68    ///
69    /// # Errors
70    ///
71    /// Returns a typed validation, planning, or runtime error when notation or execution is invalid.
72    fn einsum_notation(&self, notation: &EinsumNotation) -> Result<EagerTensor>;
73
74    /// Execute an einsum from parsed integer labels.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`Error::Validation`] for rank/shape/dtype mismatches,
79    /// [`Error::Planning`] for an invalid contraction plan, or
80    /// [`Error::Runtime`] for extension registration or backend execution
81    /// failures.
82    fn einsum_subscripts(&self, subscripts: &EinsumSubscripts) -> Result<EagerTensor>;
83}
84
85fn eager_extension_module(
86    target: EagerExtensionTarget,
87) -> tenferro_runtime::Result<Arc<dyn ExtensionModule>> {
88    let EagerExtensionTarget {
89        engine_id,
90        backend_kind,
91    } = target;
92    match backend_kind {
93        EagerExtensionBackendKind::Cpu => {
94            crate::extension::extension_module::<CpuBackend>(engine_id)
95                .map_err(eager_runtime_config_error)
96        }
97        #[cfg(feature = "cuda")]
98        EagerExtensionBackendKind::Cuda => {
99            crate::extension::extension_module::<CudaBackend>(engine_id)
100                .map_err(eager_runtime_config_error)
101        }
102        #[cfg(feature = "webgpu")]
103        EagerExtensionBackendKind::WebGpu => {
104            crate::extension::extension_module::<WebGpuBackend>(engine_id)
105                .map_err(eager_runtime_config_error)
106        }
107    }
108}
109
110fn eager_runtime_config_error(
111    source: tenferro_runtime::RuntimeConfigError,
112) -> tenferro_runtime::Error {
113    tenferro_runtime::Error::runtime_state_source(
114        "tenferro_einsum::eager_extension_module",
115        ErrorPhase::Execution,
116        source,
117    )
118}
119
120impl EagerEinsumExt for [&EagerTensor] {
121    fn einsum(&self, subscripts: &str) -> Result<EagerTensor> {
122        einsum(self, subscripts)
123    }
124
125    fn einsum_notation(&self, notation: &EinsumNotation) -> Result<EagerTensor> {
126        einsum_notation(self, notation)
127    }
128
129    fn einsum_subscripts(&self, subscripts: &EinsumSubscripts) -> Result<EagerTensor> {
130        einsum_subscripts(self, subscripts)
131    }
132}
133
134impl<const N: usize> EagerEinsumExt for [&EagerTensor; N] {
135    fn einsum(&self, subscripts: &str) -> Result<EagerTensor> {
136        einsum(self.as_slice(), subscripts)
137    }
138
139    fn einsum_notation(&self, notation: &EinsumNotation) -> Result<EagerTensor> {
140        self.as_slice().einsum_notation(notation)
141    }
142
143    fn einsum_subscripts(&self, subscripts: &EinsumSubscripts) -> Result<EagerTensor> {
144        einsum_subscripts(self.as_slice(), subscripts)
145    }
146}
147
148/// Eager tensor contraction-sugar methods.
149pub trait EagerTensorEinsumExt {
150    /// Contract two eager tensors over the requested axes.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`Error::Validation`] with `AxisOutOfBounds`, `DuplicateAxis`,
155    /// `RankMismatch`, or `ShapeMismatch` for invalid axes/shapes, or
156    /// [`Error::Runtime`] for backend execution failures.
157    fn tensordot(&self, rhs: &EagerTensor, axes: TensorDotAxes<'_>) -> Result<EagerTensor>;
158}
159
160impl EagerTensorEinsumExt for EagerTensor {
161    fn tensordot(&self, rhs: &EagerTensor, axes: TensorDotAxes<'_>) -> Result<EagerTensor> {
162        tensordot(self, rhs, axes)
163    }
164}
165
166/// Execute an einsum eagerly on [`EagerTensor`] values.
167///
168/// # Examples
169///
170/// ```
171/// use tenferro_ad::{EagerRuntime, EagerTensor};
172/// use tenferro_cpu::CpuBackend;
173/// use tenferro_einsum::EagerEinsumExt;
174/// use tenferro_tensor::Tensor;
175///
176/// let runtime = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
177/// let a = EagerTensor::from_tensor_in(
178///     Tensor::from_vec_col_major(vec![2, 3], vec![1.0_f64; 6]).unwrap(),
179///     runtime.clone(),
180/// ).unwrap();
181/// let b = EagerTensor::from_tensor_in(
182///     Tensor::from_vec_col_major(vec![3, 4], vec![1.0_f64; 12]).unwrap(),
183///     runtime,
184/// ).unwrap();
185/// let out = [&a, &b].einsum("ij,jk->ik")?;
186/// assert_eq!(out.shape(), &[2, 4]);
187/// # Ok::<(), tenferro_einsum::Error>(())
188/// ```
189///
190/// # Errors
191///
192/// Returns [`Error::InvalidSubscripts`] for malformed notation,
193/// [`Error::Validation`] for input count/rank/shape/dtype mismatches,
194/// [`Error::Planning`] when no contraction path is valid, or [`Error::Runtime`]
195/// for extension registration and backend execution failures.
196pub fn einsum(inputs: &[&EagerTensor], subscripts: &str) -> Result<EagerTensor> {
197    let notation = parse_einsum_notation(subscripts)?;
198    einsum_notation(inputs, &notation)
199}
200
201/// Execute an einsum eagerly from rank-unresolved notation.
202///
203/// # Errors
204///
205/// Returns [`Error::InvalidSubscripts`] for invalid axis tokens,
206/// [`Error::Validation`] for input count, rank, shape, or dtype mismatches,
207/// [`Error::Planning`] for an invalid contraction path, or [`Error::Runtime`]
208/// for extension registration and backend execution failures.
209pub fn einsum_notation(inputs: &[&EagerTensor], notation: &EinsumNotation) -> Result<EagerTensor> {
210    let shapes: Vec<&[usize]> = inputs.iter().map(|tensor| tensor.shape()).collect();
211    let subscripts = resolve_einsum_notation(notation, &shapes)?;
212    let subscripts = EinsumSubscripts::from(subscripts);
213    let allow_broadcast = notation
214        .inputs
215        .iter()
216        .chain(std::iter::once(&notation.output))
217        .any(|term| term.contains(&crate::EinsumAxis::Ellipsis))
218        || requires_broadcast(inputs, &subscripts);
219    einsum_subscripts_with_broadcast(inputs, &subscripts, allow_broadcast)
220}
221
222/// Execute an einsum eagerly from integer labels.
223///
224/// # Examples
225///
226/// ```
227/// use tenferro_ad::{EagerRuntime, EagerTensor};
228/// use tenferro_cpu::CpuBackend;
229/// use tenferro_einsum::{EagerEinsumExt, parse_einsum_subscripts};
230/// use tenferro_tensor::Tensor;
231///
232/// let runtime = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
233/// let a = EagerTensor::from_tensor_in(
234///     Tensor::from_vec_col_major(vec![2, 3], vec![1.0_f64; 6]).unwrap(),
235///     runtime.clone(),
236/// ).unwrap();
237/// let b = EagerTensor::from_tensor_in(
238///     Tensor::from_vec_col_major(vec![3, 4], vec![1.0_f64; 12]).unwrap(),
239///     runtime,
240/// ).unwrap();
241/// let subscripts = parse_einsum_subscripts("ij,jk->ik").unwrap();
242/// let out = [&a, &b].einsum_subscripts(&subscripts)?;
243/// assert_eq!(out.shape(), &[2, 4]);
244/// # Ok::<(), tenferro_einsum::Error>(())
245/// ```
246///
247/// # Errors
248///
249/// Returns [`Error::Validation`] for input count/rank/shape/dtype mismatches,
250/// [`Error::Planning`] when no contraction path is valid, or [`Error::Runtime`]
251/// for extension registration and backend execution failures.
252pub fn einsum_subscripts(
253    inputs: &[&EagerTensor],
254    subscripts: &EinsumSubscripts,
255) -> Result<EagerTensor> {
256    einsum_subscripts_with_broadcast(inputs, subscripts, false)
257}
258
259fn einsum_subscripts_with_broadcast(
260    inputs: &[&EagerTensor],
261    subscripts: &EinsumSubscripts,
262    allow_broadcast: bool,
263) -> Result<EagerTensor> {
264    if let Some(result) = try_direct_binary_dot_general(inputs, subscripts) {
265        return result;
266    }
267
268    if let Some(result) = try_whole_program_untracked(inputs, subscripts)? {
269        return Ok(result);
270    }
271
272    let output_shape_hint = infer_eager_output_shape(subscripts, inputs)?;
273    if !requires_broadcast(inputs, subscripts) {
274        if let Some(result) = try_expand_eager_einsum(inputs, subscripts)? {
275            return Ok(result);
276        }
277    }
278
279    let plan_spec = EinsumPlanSpec::Auto(default_auto_options());
280    let op = Arc::new(if allow_broadcast {
281        EinsumExtensionOp::with_output_shape_hint_and_broadcast(
282            subscripts.clone(),
283            output_shape_hint,
284            plan_spec,
285            true,
286        )
287    } else {
288        EinsumExtensionOp::with_output_shape_hint(subscripts.clone(), output_shape_hint, plan_spec)
289    });
290    let mut outputs =
291        apply_eager_with_targeted_extension_session(op, inputs, eager_extension_module)?;
292    outputs.pop().ok_or_else(|| {
293        Error::Runtime(tenferro_runtime::Error::MissingInput(
294            "einsum extension produced no eager output".into(),
295        ))
296    })
297}
298
299fn try_direct_binary_dot_general(
300    inputs: &[&EagerTensor],
301    subscripts: &EinsumSubscripts,
302) -> Option<Result<EagerTensor>> {
303    if inputs.len() != 2 || subscripts.inputs.len() != 2 {
304        return None;
305    }
306
307    let lhs_labels = &subscripts.inputs[0];
308    let rhs_labels = &subscripts.inputs[1];
309    if lhs_labels.len() != inputs[0].shape().len() || rhs_labels.len() != inputs[1].shape().len() {
310        return None;
311    }
312
313    if let Some(plan) =
314        try_build_exact_output_binary_dot_plan(lhs_labels, rhs_labels, &subscripts.output)
315    {
316        let (lhs, rhs) = match plan.operand_order {
317            BinaryDotOperandOrder::Original => (inputs[0], inputs[1]),
318            BinaryDotOperandOrder::Swapped => (inputs[1], inputs[0]),
319        };
320        if !exact_dot_shapes(lhs.shape(), rhs.shape(), &plan.config) {
321            return None;
322        }
323        return Some(lhs.dot_general(rhs, plan.config).map_err(Error::Runtime));
324    }
325    None
326}
327
328fn requires_broadcast(inputs: &[&EagerTensor], subscripts: &EinsumSubscripts) -> bool {
329    let mut sizes = std::collections::HashMap::<u32, usize>::new();
330    for (tensor, labels) in inputs.iter().zip(&subscripts.inputs) {
331        for (&label, &size) in labels.iter().zip(tensor.shape()) {
332            if let Some(previous) = sizes.insert(label, size) {
333                if previous != size && (previous == 1 || size == 1) {
334                    return true;
335                }
336            }
337        }
338    }
339    false
340}
341
342fn exact_dot_shapes(
343    lhs_shape: &[usize],
344    rhs_shape: &[usize],
345    config: &tenferro_tensor::DotGeneralConfig,
346) -> bool {
347    config
348        .lhs_contracting_dims
349        .iter()
350        .zip(&config.rhs_contracting_dims)
351        .all(|(&lhs, &rhs)| lhs_shape[lhs] == rhs_shape[rhs])
352        && config
353            .lhs_batch_dims
354            .iter()
355            .zip(&config.rhs_batch_dims)
356            .all(|(&lhs, &rhs)| lhs_shape[lhs] == rhs_shape[rhs])
357}
358
359/// Whether the untracked whole-program eager einsum executor is enabled.
360///
361/// Prototype gate (issue #1060 follow-up): when set, untracked N-ary eager
362/// einsum runs the whole contraction in one backend session via
363/// [`crate::eager::eager_einsum_subscripts_on_session`] instead of executing
364/// the expanded program one standard op at a time. Tracked (`requires_grad`)
365/// inputs keep the existing per-op path so eager AD recording semantics are
366/// unchanged.
367fn whole_program_untracked_enabled() -> bool {
368    std::env::var_os("TENFERRO_EAGER_WHOLE_PROGRAM").is_some()
369}
370
371/// Run an untracked eager einsum as a single backend-session program.
372///
373/// Returns `None` (so the caller falls back to the per-op expanded path) when
374/// the gate is off, there are no inputs, any input tracks gradients, or the
375/// inputs do not all share one runtime.
376fn try_whole_program_untracked(
377    inputs: &[&EagerTensor],
378    subscripts: &EinsumSubscripts,
379) -> Result<Option<EagerTensor>> {
380    if !whole_program_untracked_enabled() {
381        return Ok(None);
382    }
383    let Some(first) = inputs.first() else {
384        return Ok(None);
385    };
386    if inputs.iter().any(|tensor| tensor.tracks_grad()) {
387        return Ok(None);
388    }
389    let runtime = first.runtime();
390    if inputs
391        .iter()
392        .any(|tensor| !Arc::ptr_eq(tensor.runtime(), runtime))
393    {
394        return Ok(None);
395    }
396
397    let subs = Subscripts::from(subscripts);
398    let tensor_owners = inputs
399        .iter()
400        .map(|tensor| tensor.to_tensor().map_err(Error::Runtime))
401        .collect::<Result<Vec<Tensor>>>()?;
402    let tensors: Vec<_> = tensor_owners.iter().collect();
403    let result = runtime.with_execution_session(|backend| {
404        crate::eager::eager_einsum_subscripts_with_session(backend, &tensors, &subs)
405    })??;
406    Ok(Some(EagerTensor::from_tensor_in(result, runtime.clone())?))
407}
408
409/// Run an untracked whole-program eager einsum on an explicit contraction tree.
410///
411/// Prototype/benchmark entry (issue #1060 follow-up). Executes the whole
412/// contraction in one backend session on the caller-provided path (e.g. an
413/// externally optimized `opt_flops` order via [`crate::ContractionTree::from_pairs`]),
414/// instead of one eager op per expanded step. All inputs must be untracked and
415/// share one runtime; tracked inputs should use the per-op path to keep eager
416/// AD semantics.
417///
418/// # Examples
419///
420/// ```
421/// use tenferro_ad::{EagerRuntime, EagerTensor};
422/// use tenferro_cpu::CpuBackend;
423/// use tenferro_einsum::{ContractionTree, Subscripts};
424/// use tenferro_tensor::Tensor;
425///
426/// let runtime = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
427/// let a = EagerTensor::from_tensor_in(
428///     Tensor::from_vec_col_major(vec![2, 3], vec![1.0_f64; 6]).unwrap(),
429///     runtime.clone(),
430/// ).unwrap();
431/// let b = EagerTensor::from_tensor_in(
432///     Tensor::from_vec_col_major(vec![3, 4], vec![1.0_f64; 12]).unwrap(),
433///     runtime,
434/// ).unwrap();
435/// let subs = Subscripts::parse("ij,jk->ik").unwrap();
436/// let tree = ContractionTree::from_pairs(&subs, &[&[2, 3], &[3, 4]], &[(0, 1)]).unwrap();
437/// let out = einsum_whole_program_untracked(&[&a, &b], &tree)?;
438/// assert_eq!(out.shape(), &[2, 4]);
439/// # Ok::<(), tenferro_ad::error::Error>(())
440/// ```
441#[cfg(test)]
442fn einsum_whole_program_untracked(
443    inputs: &[&EagerTensor],
444    tree: &crate::ContractionTree,
445) -> Result<EagerTensor> {
446    let first = inputs.first().ok_or_else(|| {
447        Error::invalid_argument(
448            "einsum",
449            "inputs",
450            "einsum requires at least one input tensor",
451        )
452    })?;
453    if inputs.iter().any(|tensor| tensor.tracks_grad()) {
454        return Err(Error::invalid_argument(
455            "einsum",
456            "inputs",
457            "whole-program eager einsum requires untracked inputs",
458        ));
459    }
460    let runtime = first.runtime();
461    if inputs
462        .iter()
463        .any(|tensor| !Arc::ptr_eq(tensor.runtime(), runtime))
464    {
465        return Err(Error::invalid_argument(
466            "einsum",
467            "inputs",
468            "whole-program eager einsum requires inputs from one runtime",
469        ));
470    }
471    let tensor_owners = inputs
472        .iter()
473        .map(|tensor| tensor.to_tensor().map_err(Error::Runtime))
474        .collect::<Result<Vec<Tensor>>>()?;
475    let tensors: Vec<_> = tensor_owners.iter().collect();
476    let result = runtime.with_execution_session(|backend| {
477        crate::eager::eager_einsum_with_tree(backend, &tensors, tree)
478    })??;
479    EagerTensor::from_tensor_in(result, runtime.clone()).map_err(Error::Runtime)
480}
481
482fn try_expand_eager_einsum(
483    inputs: &[&EagerTensor],
484    subscripts: &EinsumSubscripts,
485) -> Result<Option<EagerTensor>> {
486    if inputs.len() <= 1 {
487        return Ok(None);
488    }
489
490    let shapes: Vec<Vec<usize>> = inputs
491        .iter()
492        .map(|tensor| tensor.shape().to_vec())
493        .collect();
494    let shape_refs: Vec<&[usize]> = shapes.iter().map(Vec::as_slice).collect();
495    let subs = Subscripts::from(subscripts);
496    let plan_spec = EinsumPlanSpec::Auto(default_auto_options());
497
498    let program = cached_expanded_eager_program(
499        inputs[0].runtime(),
500        subscripts,
501        &subs,
502        &plan_spec,
503        &shape_refs,
504        &shapes,
505    )?;
506    execute_eager_einsum_program(inputs, &program)
507}
508
509struct ExpandedEagerProgram {
510    compiled: CompiledProgram<StdTensorOp>,
511    input_slots: Vec<(usize, usize)>,
512}
513
514#[derive(Clone)]
515struct ExpandedEagerProgramCacheKeyData {
516    subscripts: EinsumSubscripts,
517    shapes: Vec<Vec<usize>>,
518    plan_spec: EinsumPlanSpec,
519}
520
521impl ExpandedEagerProgramCacheKeyData {
522    fn new(
523        subscripts: &EinsumSubscripts,
524        shapes: &[Vec<usize>],
525        plan_spec: &EinsumPlanSpec,
526    ) -> Self {
527        Self {
528            subscripts: subscripts.clone(),
529            shapes: shapes.to_vec(),
530            plan_spec: plan_spec.clone(),
531        }
532    }
533
534    fn matches_expanded_eager_program(
535        &self,
536        subscripts: &EinsumSubscripts,
537        shapes: &[Vec<usize>],
538        plan_spec: &EinsumPlanSpec,
539    ) -> bool {
540        self.subscripts == *subscripts
541            && self.shapes.as_slice() == shapes
542            && plan_specs_equal(&self.plan_spec, plan_spec)
543    }
544
545    fn retained_bytes(&self) -> usize {
546        saturating_sum([
547            crate::cache::einsum_subscripts_retained_bytes(&self.subscripts),
548            saturating_sum(self.shapes.iter().map(vec_retained_bytes)),
549            plan_spec_retained_bytes(&self.plan_spec),
550        ])
551    }
552}
553
554struct CachedExpandedEagerProgram {
555    key_data: ExpandedEagerProgramCacheKeyData,
556    program: Arc<ExpandedEagerProgram>,
557}
558
559fn cached_expanded_eager_program(
560    runtime: &Arc<EagerRuntime>,
561    subscripts: &EinsumSubscripts,
562    subs: &Subscripts,
563    plan_spec: &EinsumPlanSpec,
564    shape_refs: &[&[usize]],
565    shapes: &[Vec<usize>],
566) -> Result<Arc<ExpandedEagerProgram>> {
567    runtime.with_extension_execution_context(|extension_ctx| {
568        let caches = extension_ctx.caches_mut();
569        let plan_hash = plan_spec_hash(plan_spec);
570        let key = expanded_eager_program_cache_key(subscripts, shapes, plan_hash);
571        if let Some(cached) = caches.get::<CachedExpandedEagerProgram>(&key) {
572            let key_data = &cached.key_data;
573            if key_data.matches_expanded_eager_program(subscripts, shapes, plan_spec) {
574                return Ok(Arc::clone(&cached.program));
575            }
576        }
577
578        let tree = resolve_plan_spec(plan_spec, subs, shape_refs)?;
579        let program = Arc::new(build_expanded_eager_program(&tree, shapes)?);
580        let key_data = ExpandedEagerProgramCacheKeyData::new(subscripts, shapes, plan_spec);
581        let retained_bytes = saturating_sum([
582            key_data.retained_bytes(),
583            expanded_eager_program_retained_bytes(&program),
584        ]);
585        caches.put(
586            key,
587            CachedExpandedEagerProgram {
588                key_data,
589                program: Arc::clone(&program),
590            },
591            retained_bytes,
592        );
593        Ok(program)
594    })?
595}
596
597fn expanded_eager_program_cache_key(
598    subscripts: &EinsumSubscripts,
599    shapes: &[Vec<usize>],
600    plan_hash: u64,
601) -> ExtensionCacheKey {
602    let mut hasher = DefaultHasher::new();
603    subscripts.hash(&mut hasher);
604    shapes.hash(&mut hasher);
605    plan_hash.hash(&mut hasher);
606    ExtensionCacheKey::new(
607        EINSUM_EXTENSION_FAMILY_ID,
608        EINSUM_EAGER_EXPANDED_PROGRAMS_CACHE,
609        hasher.finish(),
610    )
611}
612
613fn plan_spec_hash(plan_spec: &EinsumPlanSpec) -> u64 {
614    let mut hasher = DefaultHasher::new();
615    hash_einsum_plan_spec(plan_spec, &mut hasher);
616    hasher.finish()
617}
618
619fn plan_spec_retained_bytes(plan_spec: &EinsumPlanSpec) -> usize {
620    match plan_spec {
621        EinsumPlanSpec::Auto(options) => saturating_sum([
622            std::mem::size_of::<EinsumPlanSpec>(),
623            vec_retained_bytes(&options.betas),
624        ]),
625        EinsumPlanSpec::LeftToRight => std::mem::size_of::<EinsumPlanSpec>(),
626        EinsumPlanSpec::Path(path) | EinsumPlanSpec::FixedPairs(path) => saturating_sum([
627            std::mem::size_of::<EinsumPlanSpec>(),
628            vec_retained_bytes(path),
629        ]),
630    }
631}
632
633fn build_expanded_eager_program(
634    tree: &crate::ContractionTree,
635    shapes: &[Vec<usize>],
636) -> Result<ExpandedEagerProgram> {
637    let mut builder = GraphBuilder::<StdTensorOp>::new();
638    let mut input_vals = Vec::with_capacity(shapes.len());
639    for input_idx in 0..shapes.len() {
640        let local = builder.add_input(TensorInputKey::User {
641            id: input_idx as u64,
642        });
643        input_vals.push(ValueRef::Local(local));
644    }
645
646    let result_ref = build_einsum_graph(&mut builder, tree, &input_vals, shapes)?;
647    let ValueRef::Local(result_local) = result_ref else {
648        return Err(Error::Runtime(tenferro_runtime::Error::Internal(
649            "expanded eager einsum returned an external value".into(),
650        )));
651    };
652    builder.set_outputs(vec![result_local]);
653    let graph = Arc::new(builder.build());
654    let output_key = graph.values()[result_local].key.clone();
655    let view = resolve(vec![graph]);
656    let graph = materialize_merge(&view, &[output_key]);
657    let compiled = compile(&graph);
658    let input_slots = compiled
659        .input_slots
660        .iter()
661        .zip(graph.inputs.iter())
662        .map(|(&slot, key)| {
663            let ValueKey::Input(TensorInputKey::User { id }) = key else {
664                return Err(runtime_internal(format!(
665                    "expanded eager einsum saw unexpected input key: {key:?}"
666                )));
667            };
668            Ok((slot, *id as usize))
669        })
670        .collect::<Result<_>>()?;
671
672    Ok(ExpandedEagerProgram {
673        compiled,
674        input_slots,
675    })
676}
677
678fn execute_eager_einsum_program(
679    inputs: &[&EagerTensor],
680    program: &ExpandedEagerProgram,
681) -> Result<Option<EagerTensor>> {
682    let mut slots: Vec<Option<EagerTensor>> = vec![None; program.compiled.n_slots];
683    for &(slot, input_idx) in &program.input_slots {
684        let tensor = inputs.get(input_idx).ok_or_else(|| {
685            runtime_missing(format!(
686                "expanded eager einsum input {input_idx} is missing"
687            ))
688        })?;
689        slots[slot] = Some((*tensor).clone());
690    }
691
692    let mut instruction_idx = 0;
693    while instruction_idx < program.compiled.instructions.len() {
694        if let Some((output_slot, output)) = try_execute_eager_broadcast_multiply_pattern(
695            &program.compiled.instructions,
696            instruction_idx,
697            &slots,
698            &program.compiled.output_slots,
699        )? {
700            slots[output_slot] = Some(output);
701            instruction_idx += 3;
702            continue;
703        }
704
705        let instr = &program.compiled.instructions[instruction_idx];
706        if instr.outputs.len() != 1 {
707            return Err(runtime_internal(format!(
708                "expanded eager einsum expected single-output op, got {} outputs",
709                instr.outputs.len()
710            )));
711        }
712        let input_refs: Vec<&EagerTensor> = instr
713            .inputs
714            .iter()
715            .map(|&slot| slot_tensor(&slots, slot))
716            .collect::<Result<_>>()?;
717        let output =
718            tenferro_ad::extension::apply_standard_op(instr.operation.clone(), &input_refs)?;
719        slots[instr.outputs[0]] = Some(output);
720        instruction_idx += 1;
721    }
722
723    let [output_slot] = program.compiled.output_slots.as_slice() else {
724        return Err(runtime_internal(format!(
725            "expanded eager einsum expected one graph output, got {}",
726            program.compiled.output_slots.len()
727        )));
728    };
729    slots
730        .get_mut(*output_slot)
731        .and_then(Option::take)
732        .map(Some)
733        .ok_or_else(|| runtime_missing("expanded eager einsum output slot is missing"))
734}
735
736fn expanded_eager_program_retained_bytes(program: &ExpandedEagerProgram) -> usize {
737    saturating_sum([
738        size_of::<ExpandedEagerProgram>(),
739        vec_retained_bytes(&program.input_slots),
740        compiled_program_retained_bytes(&program.compiled),
741    ])
742}
743
744fn compiled_program_retained_bytes(program: &CompiledProgram<StdTensorOp>) -> usize {
745    saturating_sum([
746        size_of::<CompiledProgram<StdTensorOp>>(),
747        vec_retained_bytes(&program.instructions),
748        vec_retained_bytes(&program.input_slots),
749        vec_retained_bytes(&program.output_slots),
750        saturating_sum(program.instructions.iter().map(instruction_retained_bytes)),
751    ])
752}
753
754fn instruction_retained_bytes(instruction: &Instruction<StdTensorOp>) -> usize {
755    saturating_sum([
756        size_of::<Instruction<StdTensorOp>>(),
757        std_tensor_op_retained_bytes(&instruction.operation),
758        vec_retained_bytes(&instruction.inputs),
759        vec_retained_bytes(&instruction.outputs),
760    ])
761}
762
763fn std_tensor_op_retained_bytes(op: &StdTensorOp) -> usize {
764    match op {
765        StdTensorOp::DotGeneral { config } => saturating_sum([
766            vec_retained_bytes(&config.lhs_contracting_dims),
767            vec_retained_bytes(&config.rhs_contracting_dims),
768            vec_retained_bytes(&config.lhs_batch_dims),
769            vec_retained_bytes(&config.rhs_batch_dims),
770        ]),
771        StdTensorOp::Transpose { perm } => vec_retained_bytes(perm),
772        StdTensorOp::Reshape { to_shape } => vec_retained_bytes(to_shape),
773        StdTensorOp::BroadcastInDim { shape, dims } => {
774            saturating_sum([vec_retained_bytes(shape), vec_retained_bytes(dims)])
775        }
776        StdTensorOp::Constant { bytes, .. } => vec_retained_bytes(bytes),
777        StdTensorOp::ReduceSum { axes }
778        | StdTensorOp::ReduceProd { axes }
779        | StdTensorOp::ReduceMax { axes }
780        | StdTensorOp::ReduceMin { axes }
781        | StdTensorOp::Reverse { axes } => vec_retained_bytes(axes),
782        StdTensorOp::DynamicSlice { slice_sizes } => vec_retained_bytes(slice_sizes),
783        StdTensorOp::GatherDynamicSliceSizes {
784            offset_dims,
785            collapsed_slice_dims,
786            start_index_map,
787            slice_sizes,
788            ..
789        } => saturating_sum([
790            vec_retained_bytes(offset_dims),
791            vec_retained_bytes(collapsed_slice_dims),
792            vec_retained_bytes(start_index_map),
793            vec_retained_bytes(slice_sizes),
794        ]),
795        _ => 0,
796    }
797}
798
799fn try_execute_eager_broadcast_multiply_pattern(
800    instructions: &[Instruction<StdTensorOp>],
801    instruction_idx: usize,
802    slots: &[Option<EagerTensor>],
803    output_slots: &[usize],
804) -> Result<Option<(usize, EagerTensor)>> {
805    if instruction_idx + 2 >= instructions.len() {
806        return Ok(None);
807    }
808    let lhs_bc = &instructions[instruction_idx];
809    let rhs_bc = &instructions[instruction_idx + 1];
810    let multiply = &instructions[instruction_idx + 2];
811
812    let StdTensorOp::BroadcastInDim {
813        shape: lhs_shape_exprs,
814        dims: lhs_dims,
815    } = &lhs_bc.operation
816    else {
817        return Ok(None);
818    };
819    let StdTensorOp::BroadcastInDim {
820        shape: rhs_shape_exprs,
821        dims: rhs_dims,
822    } = &rhs_bc.operation
823    else {
824        return Ok(None);
825    };
826    if !matches!(multiply.operation, StdTensorOp::Mul)
827        || lhs_bc.outputs.len() != 1
828        || rhs_bc.outputs.len() != 1
829        || multiply.outputs.len() != 1
830        || multiply.inputs.len() != 2
831        || lhs_bc.inputs.is_empty()
832        || rhs_bc.inputs.is_empty()
833        || multiply.inputs[0] != lhs_bc.outputs[0]
834        || multiply.inputs[1] != rhs_bc.outputs[0]
835    {
836        return Ok(None);
837    }
838
839    let lhs_bc_slot = lhs_bc.outputs[0];
840    let rhs_bc_slot = rhs_bc.outputs[0];
841    if output_slots.contains(&lhs_bc_slot)
842        || output_slots.contains(&rhs_bc_slot)
843        || instructions[instruction_idx + 3..]
844            .iter()
845            .any(|instr| instr.inputs.contains(&lhs_bc_slot) || instr.inputs.contains(&rhs_bc_slot))
846    {
847        return Ok(None);
848    }
849
850    let lhs = slot_tensor(slots, lhs_bc.inputs[0])?;
851    let rhs = slot_tensor(slots, rhs_bc.inputs[0])?;
852    let lhs_shape = eval_shape_exprs(slots, &lhs_bc.inputs, lhs_shape_exprs)?;
853    let rhs_shape = eval_shape_exprs(slots, &rhs_bc.inputs, rhs_shape_exprs)?;
854    let Some(output) =
855        backend_broadcast_multiply_untracked(lhs, &lhs_shape, lhs_dims, rhs, &rhs_shape, rhs_dims)?
856    else {
857        return Ok(None);
858    };
859
860    Ok(Some((multiply.outputs[0], output)))
861}
862
863#[allow(clippy::too_many_arguments)]
864fn backend_broadcast_multiply_untracked(
865    lhs: &EagerTensor,
866    lhs_shape: &[usize],
867    lhs_dims: &[usize],
868    rhs: &EagerTensor,
869    rhs_shape: &[usize],
870    rhs_dims: &[usize],
871) -> Result<Option<EagerTensor>> {
872    if !Arc::ptr_eq(lhs.runtime(), rhs.runtime()) {
873        return Err(tenferro_runtime::Error::ContextMismatch {
874            lhs: lhs.ctx_id(),
875            rhs: rhs.ctx_id(),
876        }
877        .into());
878    }
879    if lhs.tracks_grad() || rhs.tracks_grad() {
880        return Ok(None);
881    }
882
883    let runtime = lhs.runtime();
884    let value = runtime.with_execution_session(|backend| {
885        backend.execute_broadcast_multiply_value(
886            lhs.tensor_read(),
887            lhs_shape,
888            lhs_dims,
889            rhs.tensor_read(),
890            rhs_shape,
891            rhs_dims,
892        )
893    })??;
894
895    Ok(value
896        .map(|value| adopt_untracked_eager_value(runtime.clone(), value))
897        .transpose()?)
898}
899
900fn eval_shape_exprs(
901    slots: &[Option<EagerTensor>],
902    input_slots: &[usize],
903    shape: &[DimExpr],
904) -> Result<Vec<usize>> {
905    let inputs = input_slots
906        .iter()
907        .map(|&slot| slot_tensor(slots, slot))
908        .collect::<Result<Vec<_>>>()?;
909    let input_shapes = inputs
910        .iter()
911        .map(|tensor| tensor.shape())
912        .collect::<Vec<_>>();
913    DimExpr::eval_all(shape, &input_shapes).map_err(|error| {
914        runtime_extension_error(
915            "einsum",
916            ErrorKind::Validation(ValidationKind::InvalidArgument),
917            error,
918        )
919    })
920}
921
922fn slot_tensor(slots: &[Option<EagerTensor>], slot: usize) -> Result<&EagerTensor> {
923    slots.get(slot).and_then(Option::as_ref).ok_or_else(|| {
924        Error::Runtime(tenferro_runtime::Error::MissingInput(format!(
925            "expanded eager einsum missing value for slot {slot}"
926        )))
927    })
928}
929
930fn infer_eager_output_shape(
931    subscripts: &EinsumSubscripts,
932    inputs: &[&EagerTensor],
933) -> Result<Vec<tenferro_runtime::SymDim>> {
934    if inputs.is_empty() {
935        return Err(Error::invalid_argument(
936            "einsum",
937            "inputs",
938            "einsum requires at least one input tensor",
939        ));
940    }
941    if subscripts.inputs.len() != inputs.len() {
942        return Err(Error::invalid_argument(
943            "einsum",
944            "inputs",
945            format!(
946                "einsum subscripts expect {} inputs, got {}",
947                subscripts.inputs.len(),
948                inputs.len()
949            ),
950        ));
951    }
952
953    let mut label_dims = std::collections::HashMap::new();
954    for (labels, tensor) in subscripts.inputs.iter().zip(inputs.iter()) {
955        let shape = tensor.shape();
956        if labels.len() != shape.len() {
957            return Err(Error::validation(
958                "einsum",
959                ValidationError::RankMismatch {
960                    expected: labels.len(),
961                    actual: shape.len(),
962                },
963            ));
964        }
965        for (&label, &dim) in labels.iter().zip(shape.iter()) {
966            if let Some(existing) = label_dims.get_mut(&label) {
967                if *existing != dim && *existing != 1 && dim != 1 {
968                    return Err(Error::validation(
969                        "einsum",
970                        ShapeMismatch::ExpectedActual {
971                            expected: tenferro_tensor::ShapeVec::from_vec(vec![*existing]),
972                            actual: tenferro_tensor::ShapeVec::from_vec(vec![dim]),
973                        }
974                        .into(),
975                    ));
976                }
977                if *existing == 1 {
978                    *existing = dim;
979                }
980            } else {
981                label_dims.insert(label, dim);
982            }
983        }
984    }
985
986    subscripts
987        .output
988        .iter()
989        .map(|label| {
990            label_dims
991                .get(label)
992                .copied()
993                .map(tenferro_runtime::SymDim::from)
994                .ok_or_else(|| {
995                    Error::invalid_argument(
996                        "einsum",
997                        "output",
998                        format!("einsum output label {label} is missing from input labels"),
999                    )
1000                })
1001        })
1002        .collect()
1003}
1004
1005fn runtime_extension_error<E>(op: &'static str, kind: ErrorKind, source: E) -> Error
1006where
1007    E: StdError + Send + Sync + 'static,
1008{
1009    Error::Runtime(tenferro_runtime::Error::extension(
1010        op,
1011        ErrorPhase::Execution,
1012        EINSUM_EXTENSION_FAMILY_ID,
1013        kind,
1014        source,
1015    ))
1016}
1017
1018fn runtime_internal(message: impl Into<String>) -> Error {
1019    Error::Runtime(tenferro_runtime::Error::Internal(message.into()))
1020}
1021
1022fn runtime_missing(message: impl Into<String>) -> Error {
1023    Error::Runtime(tenferro_runtime::Error::MissingInput(message.into()))
1024}
1025
1026/// Execute a NumPy-style tensor contraction on [`EagerTensor`] values.
1027///
1028/// This helper lives in the einsum extension trait surface because it is
1029/// contraction sugar over `dot_general`, not a linear algebra facade.
1030///
1031/// # Examples
1032///
1033/// ```
1034/// use tenferro_tensor::Tensor;
1035/// use tenferro_cpu::CpuBackend;
1036/// use tenferro_ad::{EagerRuntime, EagerTensor};
1037/// use tenferro_einsum::{EagerTensorEinsumExt, TensorDotAxes};
1038///
1039/// let ctx = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
1040/// let lhs = EagerTensor::from_tensor_in(
1041///     Tensor::from_vec_col_major(vec![2, 3], vec![1.0_f64; 6]).unwrap(),
1042///     ctx.clone(),
1043/// ).unwrap();
1044/// let rhs = EagerTensor::from_tensor_in(
1045///     Tensor::from_vec_col_major(vec![3, 4], vec![1.0_f64; 12]).unwrap(),
1046///     ctx,
1047/// ).unwrap();
1048/// let out = lhs.tensordot(&rhs, TensorDotAxes::Count(1)).unwrap();
1049///
1050/// assert_eq!(out.shape(), &[2, 4]);
1051/// # Ok::<(), tenferro_einsum::Error>(())
1052/// ```
1053///
1054/// # Errors
1055///
1056/// Returns [`Error::Validation`] with `AxisOutOfBounds`, `DuplicateAxis`,
1057/// `RankMismatch`, or `ShapeMismatch` for invalid contraction axes and shapes,
1058/// or [`Error::Runtime`] for eager backend execution failures.
1059pub fn tensordot(
1060    lhs: &EagerTensor,
1061    rhs: &EagerTensor,
1062    axes: TensorDotAxes<'_>,
1063) -> Result<EagerTensor> {
1064    let config = crate::tensordot::dot_general_config(axes, lhs.shape().len(), rhs.shape().len())?;
1065    crate::tensordot::validate_concrete_contract_dims(lhs.shape(), rhs.shape(), &config)?;
1066    lhs.dot_general(rhs, config).map_err(Error::Runtime)
1067}
1068
1069#[cfg(test)]
1070mod tests;