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