Skip to main content

tenferro_einsum/
extension.rs

1use std::any::Any;
2use std::collections::hash_map::DefaultHasher;
3use std::collections::HashMap;
4#[cfg(feature = "autodiff")]
5use std::collections::HashSet;
6use std::hash::{Hash, Hasher};
7use std::sync::Arc;
8
9use computegraph::graph::GraphBuilder;
10use computegraph::types::ValueRef;
11#[cfg(feature = "autodiff")]
12use tenferro_ad::semantic_extension::{
13    AdValue, ResidualSpec, SemanticAdError, SemanticAdRuleRole, SemanticExtensionRegistryError,
14    SemanticExtensionRuleSet, SemanticLinearTransposeRequest, SemanticLinearTransposeRule,
15    SemanticLinearizeRequest, SemanticLinearizeResult, SemanticLinearizeRule,
16    SemanticPrimalVjpRequest, SemanticPrimalVjpRule,
17};
18use tenferro_extension_macros::define_extension_runtime;
19#[cfg(feature = "autodiff")]
20use tenferro_ops::dim_expr::DimExpr;
21use tenferro_ops::ext_op::{
22    ExtensionLoweringError, ExtensionLoweringResult, ExtensionOp, ExtensionStandardLowering,
23};
24use tenferro_ops::std_tensor_op::StdTensorOp;
25use tenferro_ops::sym_dim::SymDim;
26use tenferro_runtime::extension::{ExtensionCacheKey, ExtensionExecutionContext};
27#[cfg(feature = "autodiff")]
28use tenferro_runtime::program::{
29    CoreSemanticOp, ProgramValue, ProgramValueMetadata, SemanticProgramBuilder,
30};
31use tenferro_tensor::{
32    BackendSession, DType, Error as TensorError, Tensor, TensorBackend, TensorRead,
33};
34
35use crate::builder::build_einsum_graph;
36use crate::cache::{
37    einsum_subscripts_retained_bytes, saturating_sum, vec_retained_bytes,
38    EINSUM_EXTENSION_FAMILY_ID, EINSUM_RUNTIME_PLANS_CACHE,
39};
40#[cfg(test)]
41use crate::optimize::default_auto_options;
42#[cfg(feature = "autodiff")]
43use crate::optimize::jax_path_to_v1_pairs;
44use crate::optimize::{hash_einsum_plan_spec, plan_specs_equal, resolve_plan_spec, EinsumPlanSpec};
45#[cfg(feature = "autodiff")]
46use crate::util::map_label_occurrences;
47use crate::{
48    ContractionTree, EinsumSubscripts, Error as EinsumError, Result as EinsumResult, Subscripts,
49};
50
51/// Standard einsum extension payload.
52///
53/// This mirrors the current `tenferro.einsum.v1` payload shape. Runtime-owned
54/// execution goes through [`EinsumRuntime`].
55#[derive(Clone)]
56pub(crate) struct EinsumExtensionOp {
57    subscripts: EinsumSubscripts,
58    plan_spec: EinsumPlanSpec,
59    output_shape_hint: Option<Vec<SymDim>>,
60    allow_broadcast: bool,
61}
62
63impl std::fmt::Debug for EinsumExtensionOp {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("EinsumExtensionOp")
66            .field("subscripts", &self.subscripts)
67            .field("plan_spec", &self.plan_spec)
68            .field("output_shape_hint", &self.output_shape_hint)
69            .field("allow_broadcast", &self.allow_broadcast)
70            .finish()
71    }
72}
73
74impl EinsumExtensionOp {
75    /// Create an einsum extension payload without a precomputed plan.
76    #[must_use]
77    #[cfg(test)]
78    pub(crate) fn new(subscripts: EinsumSubscripts) -> Self {
79        Self::with_plan_spec(subscripts, EinsumPlanSpec::Auto(default_auto_options()))
80    }
81
82    #[must_use]
83    pub(crate) fn with_plan_spec(subscripts: EinsumSubscripts, plan_spec: EinsumPlanSpec) -> Self {
84        Self {
85            subscripts,
86            plan_spec,
87            output_shape_hint: None,
88            allow_broadcast: false,
89        }
90    }
91
92    pub(crate) fn with_plan_spec_and_broadcast(
93        subscripts: EinsumSubscripts,
94        plan_spec: EinsumPlanSpec,
95        allow_broadcast: bool,
96    ) -> Self {
97        let mut op = Self::with_plan_spec(subscripts, plan_spec);
98        op.allow_broadcast = allow_broadcast;
99        op
100    }
101
102    /// Create an einsum extension payload with an explicit output shape hint.
103    #[must_use]
104    #[cfg(any(feature = "autodiff", test))]
105    pub(crate) fn with_output_shape_hint(
106        subscripts: EinsumSubscripts,
107        output_shape_hint: Vec<SymDim>,
108        plan_spec: EinsumPlanSpec,
109    ) -> Self {
110        let mut op = Self::with_plan_spec(subscripts, plan_spec);
111        op.output_shape_hint = Some(output_shape_hint);
112        op
113    }
114
115    #[must_use]
116    #[cfg(feature = "autodiff")]
117    pub(crate) fn with_output_shape_hint_and_broadcast(
118        subscripts: EinsumSubscripts,
119        output_shape_hint: Vec<SymDim>,
120        plan_spec: EinsumPlanSpec,
121        allow_broadcast: bool,
122    ) -> Self {
123        let mut op = Self::with_plan_spec_and_broadcast(subscripts, plan_spec, allow_broadcast);
124        op.output_shape_hint = Some(output_shape_hint);
125        op
126    }
127
128    /// Return the canonical subscripts.
129    #[must_use]
130    pub(crate) fn subscripts(&self) -> &EinsumSubscripts {
131        &self.subscripts
132    }
133
134    /// Return the shape-independent planning policy.
135    #[must_use]
136    pub(crate) fn plan_spec(&self) -> &EinsumPlanSpec {
137        &self.plan_spec
138    }
139
140    #[must_use]
141    #[cfg(feature = "autodiff")]
142    pub(crate) fn allow_broadcast(&self) -> bool {
143        self.allow_broadcast
144    }
145}
146
147impl ExtensionOp for EinsumExtensionOp {
148    fn family_id(&self) -> &'static str {
149        EINSUM_EXTENSION_FAMILY_ID
150    }
151
152    fn payload_hash(&self, hasher: &mut dyn Hasher) {
153        hasher.write_usize(self.subscripts.inputs.len());
154        for input in &self.subscripts.inputs {
155            hasher.write_usize(input.len());
156            for label in input {
157                hasher.write_u32(*label);
158            }
159        }
160        hasher.write_usize(self.subscripts.output.len());
161        for label in &self.subscripts.output {
162            hasher.write_u32(*label);
163        }
164        hash_einsum_plan_spec(self.plan_spec(), hasher);
165        hasher.write_u8(u8::from(self.allow_broadcast));
166        if let Some(shape) = &self.output_shape_hint {
167            hasher.write_usize(shape.len());
168            for dim in shape {
169                match dim.constant_value() {
170                    Some(value) => {
171                        hasher.write_u8(1);
172                        hasher.write_usize(value);
173                    }
174                    None => hasher.write_u8(0),
175                }
176            }
177        } else {
178            hasher.write_usize(usize::MAX);
179        }
180    }
181
182    fn payload_eq(&self, other: &dyn ExtensionOp) -> bool {
183        other.as_any().downcast_ref::<Self>().is_some_and(|that| {
184            self.subscripts == that.subscripts
185                && plan_specs_equal(self.plan_spec(), that.plan_spec())
186                && self.output_shape_hint == that.output_shape_hint
187                && self.allow_broadcast == that.allow_broadcast
188        })
189    }
190
191    fn clone_arc(&self) -> Arc<dyn ExtensionOp> {
192        Arc::new(self.clone())
193    }
194
195    fn as_any(&self) -> &dyn Any {
196        self
197    }
198
199    fn input_count(&self) -> usize {
200        self.subscripts.inputs.len()
201    }
202
203    fn output_count(&self) -> usize {
204        1
205    }
206
207    fn semantic_effects(&self) -> tenferro_ops::ext_op::ExtensionEffectDeclaration<'_> {
208        tenferro_ops::ext_op::ExtensionEffectDeclaration::Declared(&[])
209    }
210
211    fn semantic_aliases(&self) -> tenferro_ops::ext_op::ExtensionAliasDeclaration<'_> {
212        tenferro_ops::ext_op::ExtensionAliasDeclaration::AllFresh
213    }
214
215    fn infer_output_meta(
216        &self,
217        ctx: &mut tenferro_ops::ExtensionShapeContext<'_>,
218    ) -> tenferro_tensor::Result<Vec<(DType, Vec<SymDim>)>> {
219        let input_dtypes = (0..self.input_count())
220            .map(|input| ctx.input_dtype(input))
221            .collect::<Result<Vec<_>, _>>()?;
222        let input_shapes = (0..self.input_count())
223            .map(|input| ctx.input_shape(input).map(<[_]>::to_vec))
224            .collect::<Result<Vec<_>, _>>()?;
225
226        let mut label_dims: HashMap<u32, SymDim> = HashMap::new();
227        for (labels, shape) in self.subscripts.inputs.iter().zip(input_shapes.iter()) {
228            if labels.len() != shape.len() {
229                return Err(TensorError::rank_mismatch(
230                    "einsum",
231                    labels.len(),
232                    shape.len(),
233                ));
234            }
235            for (&label, dim) in labels.iter().zip(shape.iter()) {
236                if let Some(existing) = label_dims.get_mut(&label) {
237                    if !self.allow_broadcast {
238                        ctx.require_equal(existing.clone(), dim.clone())?;
239                        continue;
240                    }
241                    match (existing.constant_value(), dim.constant_value()) {
242                        (Some(lhs), Some(rhs)) if lhs == rhs || lhs == 1 || rhs == 1 => {
243                            *existing = SymDim::from(if lhs == 1 { rhs } else { lhs });
244                        }
245                        (Some(_lhs), Some(_rhs)) => {
246                            ctx.require_equal(existing.clone(), dim.clone())?;
247                        }
248                        (Some(1), None) => *existing = dim.clone(),
249                        (None, Some(1)) => {}
250                        _ => {}
251                    }
252                } else {
253                    label_dims.insert(label, dim.clone());
254                }
255            }
256        }
257
258        let output_shape = match &self.output_shape_hint {
259            Some(shape) if shape.iter().all(|dim| dim.constant_value().is_some()) => shape.clone(),
260            _ => self
261                .subscripts
262                .output
263                .iter()
264                .map(|label| label_dims.get(label).cloned())
265                .collect::<Option<Vec<_>>>()
266                .ok_or_else(|| {
267                    TensorError::invalid_argument(
268                        "einsum",
269                        "output labels",
270                        "must be present in input metadata",
271                    )
272                })?,
273        };
274        if output_shape.len() != self.subscripts.output.len() {
275            return Err(TensorError::rank_mismatch(
276                "einsum",
277                self.subscripts.output.len(),
278                output_shape.len(),
279            ));
280        }
281        Ok(vec![(
282            promote_dtypes(input_dtypes.iter().copied()),
283            output_shape,
284        )])
285    }
286
287    fn lower_to_standard_ops(
288        &self,
289        builder: &mut GraphBuilder<StdTensorOp>,
290        inputs: &[ValueRef<StdTensorOp>],
291        input_dtypes: &[DType],
292        input_shapes: &[&[SymDim]],
293    ) -> ExtensionLoweringResult {
294        if inputs.len() != self.input_count()
295            || input_dtypes.len() != self.input_count()
296            || input_shapes.len() != self.input_count()
297        {
298            return Err(ExtensionLoweringError::new(format!(
299                "einsum extension expects {} inputs, got values={}, dtypes={}, shapes={}",
300                self.input_count(),
301                inputs.len(),
302                input_dtypes.len(),
303                input_shapes.len()
304            )));
305        }
306
307        let Some(shapes) = concrete_sym_shape_slices(input_shapes) else {
308            return Ok(ExtensionStandardLowering::Unsupported);
309        };
310        let shape_refs: Vec<&[usize]> = shapes.iter().map(Vec::as_slice).collect();
311        let subs = Subscripts::from(&self.subscripts);
312        let tree = resolve_plan_spec(self.plan_spec(), &subs, &shape_refs).map_err(|source| {
313            ExtensionLoweringError::from_source_with_kind(source.kind(), source)
314        })?;
315        let output = build_einsum_graph(builder, &tree, inputs, &shapes).map_err(|source| {
316            ExtensionLoweringError::from_source_with_kind(source.kind(), source)
317        })?;
318        Ok(ExtensionStandardLowering::Lowered(vec![output]))
319    }
320}
321
322fn concrete_sym_shape_slices(input_shapes: &[&[SymDim]]) -> Option<Vec<Vec<usize>>> {
323    input_shapes
324        .iter()
325        .map(|shape| {
326            shape
327                .iter()
328                .map(SymDim::constant_value)
329                .collect::<Option<Vec<_>>>()
330        })
331        .collect()
332}
333
334/// Return the semantic-program einsum extension AD rules.
335#[cfg(feature = "autodiff")]
336///
337/// # Errors
338///
339/// Returns [`SemanticExtensionRegistryError::MalformedFamilyId`] for an
340/// invalid family identifier, or
341/// [`SemanticExtensionRegistryError::DuplicateRule`] for a duplicate role.
342pub fn semantic_ad_rules(
343) -> std::result::Result<SemanticExtensionRuleSet, SemanticExtensionRegistryError> {
344    SemanticExtensionRuleSet::new()
345        .with_linearize(Arc::new(EinsumAdRule))?
346        .with_linear_transpose(Arc::new(EinsumAdRule))?
347        .with_primal_vjp(Arc::new(EinsumAdRule))
348}
349
350#[derive(Debug)]
351#[cfg(feature = "autodiff")]
352struct EinsumAdRule;
353
354#[cfg(feature = "autodiff")]
355impl SemanticLinearizeRule for EinsumAdRule {
356    fn family_id(&self) -> &'static str {
357        EINSUM_EXTENSION_FAMILY_ID
358    }
359
360    fn linearize(
361        &self,
362        request: SemanticLinearizeRequest<'_>,
363        builder: &mut SemanticProgramBuilder,
364    ) -> std::result::Result<SemanticLinearizeResult, SemanticAdError> {
365        let op = semantic_einsum_payload(request.op(), SemanticAdRuleRole::Linearize)?;
366        if !request.active_outputs()[0] {
367            return Ok(SemanticLinearizeResult::new([AdValue::Absent], []));
368        }
369        let mut terms = Vec::new();
370        for (active_idx, tangent) in request.tangent_inputs().iter().copied().enumerate() {
371            let AdValue::Value(tangent) = tangent else {
372                continue;
373            };
374            let inputs: Vec<_> = request
375                .primal_inputs()
376                .iter()
377                .copied()
378                .enumerate()
379                .map(|(input_idx, primal)| {
380                    if input_idx == active_idx {
381                        tangent
382                    } else {
383                        primal
384                    }
385                })
386                .collect();
387            terms.push(builder.add_extension(Arc::new(op.clone()), &inputs)?[0]);
388        }
389        let tangent = semantic_sum_terms(builder, terms)?;
390        Ok(SemanticLinearizeResult::new([tangent], []))
391    }
392}
393
394#[cfg(feature = "autodiff")]
395impl SemanticLinearTransposeRule for EinsumAdRule {
396    fn family_id(&self) -> &'static str {
397        EINSUM_EXTENSION_FAMILY_ID
398    }
399
400    fn residual_mask(&self) -> ResidualSpec {
401        // The einsum VJP reads every non-active operand as a tensor operand
402        // (conjugate + contract with the cotangent); which operands are read
403        // depends on the active-input configuration, so all inputs are
404        // declared. The output is only used for its shape.
405        ResidualSpec::all_inputs()
406    }
407
408    fn linear_transpose(
409        &self,
410        request: SemanticLinearTransposeRequest<'_>,
411        builder: &mut SemanticProgramBuilder,
412    ) -> std::result::Result<Box<[AdValue]>, SemanticAdError> {
413        let primal_inputs = (0..request.primal_input_count())
414            .map(|index| request.primal_input_value(index))
415            .collect::<Result<Vec<_>, _>>()?;
416        let primal_output_metadata = request.primal_output_meta(0)?;
417        semantic_einsum_vjp(
418            request.op(),
419            &primal_inputs,
420            primal_output_metadata,
421            request.cotangent_outputs(),
422            request.active_inputs(),
423            request.residual_mask(),
424            builder,
425        )
426    }
427}
428
429#[cfg(feature = "autodiff")]
430impl SemanticPrimalVjpRule for EinsumAdRule {
431    fn family_id(&self) -> &'static str {
432        EINSUM_EXTENSION_FAMILY_ID
433    }
434
435    fn residual_mask(&self) -> ResidualSpec {
436        // Same operand set as the linear transpose: every non-active input is
437        // read as a tensor; the output only for its shape.
438        ResidualSpec::all_inputs()
439    }
440
441    fn primal_vjp(
442        &self,
443        request: SemanticPrimalVjpRequest<'_>,
444        builder: &mut SemanticProgramBuilder,
445    ) -> std::result::Result<Box<[AdValue]>, SemanticAdError> {
446        let primal_inputs = (0..request.primal_input_count())
447            .map(|index| request.primal_input_value(index))
448            .collect::<Result<Vec<_>, _>>()?;
449        let primal_output_metadata = request.primal_output_meta(0)?;
450        semantic_einsum_vjp(
451            request.op(),
452            &primal_inputs,
453            primal_output_metadata,
454            request.cotangent_outputs(),
455            request.active_inputs(),
456            request.residual_mask(),
457            builder,
458        )
459    }
460}
461
462#[cfg(feature = "autodiff")]
463fn semantic_einsum_vjp(
464    payload: &dyn ExtensionOp,
465    primal_inputs: &[ProgramValue],
466    primal_output_metadata: &ProgramValueMetadata,
467    cotangent_outputs: &[AdValue],
468    active_inputs: &[bool],
469    residual_mask: ResidualSpec,
470    builder: &mut SemanticProgramBuilder,
471) -> std::result::Result<Box<[AdValue]>, SemanticAdError> {
472    let op = semantic_einsum_payload(payload, SemanticAdRuleRole::LinearTranspose)?;
473    let input_count = op.subscripts.inputs.len();
474    let AdValue::Value(cotangent) = cotangent_outputs[0] else {
475        return Ok(vec![AdValue::Absent; input_count].into_boxed_slice());
476    };
477    let primal_input_shapes = primal_inputs
478        .iter()
479        .copied()
480        .map(|value| semantic_value_shape(builder, value))
481        .collect::<std::result::Result<Vec<_>, _>>()?;
482    let cotangent_shape = semantic_metadata_shape(primal_output_metadata)?;
483
484    let input_labels = &op.subscripts.inputs;
485    let output_labels = &op.subscripts.output;
486    let mut result = Vec::with_capacity(input_count);
487    for active_idx in 0..input_count {
488        if !active_inputs[active_idx] {
489            result.push(AdValue::Absent);
490            continue;
491        }
492        let mut available_labels: HashSet<u32> = output_labels.iter().copied().collect();
493        for (input_idx, labels) in input_labels.iter().enumerate() {
494            if input_idx != active_idx {
495                available_labels.extend(labels.iter().copied());
496            }
497        }
498        let vjp_output_labels: Vec<u32> = input_labels[active_idx]
499            .iter()
500            .copied()
501            .filter(|label| available_labels.contains(label))
502            .collect();
503        let mut vjp_input_labels = vec![output_labels.clone()];
504        let mut vjp_inputs = vec![cotangent];
505        let mut vjp_input_shapes = vec![cotangent_shape.clone()];
506        for input_idx in 0..input_count {
507            if input_idx == active_idx {
508                continue;
509            }
510            vjp_input_labels.push(input_labels[input_idx].clone());
511            vjp_input_shapes.push(primal_input_shapes[input_idx].clone());
512            debug_assert!(
513                residual_mask.declares_input(input_idx),
514                "einsum transpose read primal input {input_idx} as a tensor operand but the \
515                 residual mask does not declare it; declare it in the einsum rule's residual mask"
516            );
517            vjp_inputs.push(semantic_conjugate_if_complex(
518                builder,
519                primal_inputs[input_idx],
520            )?);
521        }
522        let vjp_op = semantic_vjp_einsum_op(
523            op,
524            active_idx,
525            EinsumSubscripts {
526                inputs: vjp_input_labels,
527                output: vjp_output_labels.clone(),
528            },
529            &vjp_input_shapes,
530        )?;
531        let mut input_cotangent = builder.add_extension(Arc::new(vjp_op), &vjp_inputs)?[0];
532        if vjp_output_labels != input_labels[active_idx] {
533            input_cotangent = semantic_broadcast_einsum_vjp(
534                builder,
535                input_cotangent,
536                &vjp_output_labels,
537                &input_labels[active_idx],
538                primal_input_shapes[active_idx].clone(),
539            )?;
540        }
541        result.push(AdValue::Value(input_cotangent));
542    }
543    Ok(result.into_boxed_slice())
544}
545
546#[cfg(feature = "autodiff")]
547fn semantic_vjp_einsum_op(
548    primal_op: &EinsumExtensionOp,
549    active_idx: usize,
550    subscripts: EinsumSubscripts,
551    input_shapes: &[Vec<DimExpr>],
552) -> std::result::Result<EinsumExtensionOp, SemanticAdError> {
553    let plan_spec =
554        vjp_plan_spec_for_active(primal_op.plan_spec(), primal_op.input_count(), active_idx)?;
555    let sym_shapes: Vec<Vec<SymDim>> = input_shapes
556        .iter()
557        .enumerate()
558        .map(|(input_idx, shape)| {
559            let tensor_id = u64::MAX - input_idx as u64;
560            shape
561                .iter()
562                .enumerate()
563                .map(|(axis, dim)| match dim {
564                    DimExpr::Const(value) => SymDim::from(*value),
565                    _ => SymDim::tensor_axis(tensor_id, axis),
566                })
567                .collect()
568        })
569        .collect();
570    if let Some(concrete_shapes) = concrete_sym_shapes(&sym_shapes) {
571        let shape_refs: Vec<&[usize]> = concrete_shapes.iter().map(Vec::as_slice).collect();
572        let raw_subscripts = Subscripts::from(&subscripts);
573        let _tree = resolve_plan_spec(&plan_spec, &raw_subscripts, &shape_refs)
574            .map_err(|source| semantic_einsum_unsupported(source.to_string()))?;
575    }
576    Ok(EinsumExtensionOp::with_plan_spec_and_broadcast(
577        subscripts,
578        plan_spec,
579        primal_op.allow_broadcast(),
580    ))
581}
582
583#[cfg(feature = "autodiff")]
584fn semantic_value_shape(
585    builder: &SemanticProgramBuilder,
586    value: ProgramValue,
587) -> std::result::Result<Vec<DimExpr>, SemanticAdError> {
588    semantic_metadata_shape(builder.value_metadata(value)?)
589}
590
591#[cfg(feature = "autodiff")]
592fn semantic_metadata_shape(
593    metadata: &ProgramValueMetadata,
594) -> std::result::Result<Vec<DimExpr>, SemanticAdError> {
595    metadata
596        .shape()
597        .iter()
598        .map(|extent| {
599            extent.bound_expr().cloned().ok_or_else(|| {
600                semantic_einsum_unsupported(
601                    "einsum semantic AD requires a symbolic expression for every extent",
602                )
603            })
604        })
605        .collect()
606}
607
608#[cfg(feature = "autodiff")]
609fn semantic_conjugate_if_complex(
610    builder: &mut SemanticProgramBuilder,
611    value: ProgramValue,
612) -> std::result::Result<ProgramValue, SemanticAdError> {
613    if matches!(
614        builder.value_metadata(value)?.dtype(),
615        DType::C32 | DType::C64
616    ) {
617        Ok(builder.add_op(CoreSemanticOp::Conj, &[value])?[0])
618    } else {
619        Ok(value)
620    }
621}
622
623#[cfg(feature = "autodiff")]
624fn semantic_broadcast_einsum_vjp(
625    builder: &mut SemanticProgramBuilder,
626    cotangent: ProgramValue,
627    cotangent_labels: &[u32],
628    input_labels: &[u32],
629    shape: Vec<DimExpr>,
630) -> std::result::Result<ProgramValue, SemanticAdError> {
631    let dims = map_label_occurrences(cotangent_labels, input_labels).ok_or_else(|| {
632        semantic_einsum_unsupported(format!(
633            "einsum VJP cannot remap labels {cotangent_labels:?} into {input_labels:?}"
634        ))
635    })?;
636    let broadcast =
637        builder.add_op(CoreSemanticOp::BroadcastInDim { shape, dims }, &[cotangent])?[0];
638    semantic_project_repeated_labels(builder, broadcast, input_labels)
639}
640
641#[cfg(feature = "autodiff")]
642fn semantic_project_repeated_labels(
643    builder: &mut SemanticProgramBuilder,
644    cotangent: ProgramValue,
645    labels: &[u32],
646) -> std::result::Result<ProgramValue, SemanticAdError> {
647    let mut result = cotangent;
648    let mut first_axis_by_label = HashMap::new();
649    for (axis_b, label) in labels.iter().copied().enumerate() {
650        let Some(&axis_a) = first_axis_by_label.get(&label) else {
651            first_axis_by_label.insert(label, axis_b);
652            continue;
653        };
654        let extracted =
655            builder.add_op(CoreSemanticOp::ExtractDiag { axis_a, axis_b }, &[result])?[0];
656        result = builder.add_op(CoreSemanticOp::EmbedDiag { axis_a, axis_b }, &[extracted])?[0];
657    }
658    Ok(result)
659}
660
661#[cfg(feature = "autodiff")]
662fn semantic_sum_terms(
663    builder: &mut SemanticProgramBuilder,
664    terms: Vec<ProgramValue>,
665) -> std::result::Result<AdValue, SemanticAdError> {
666    let mut terms = terms.into_iter();
667    let Some(mut sum) = terms.next() else {
668        return Ok(AdValue::Absent);
669    };
670    for term in terms {
671        sum = builder.add_op(CoreSemanticOp::Add, &[sum, term])?[0];
672    }
673    Ok(AdValue::Value(sum))
674}
675
676#[cfg(feature = "autodiff")]
677fn semantic_einsum_payload(
678    op: &dyn ExtensionOp,
679    role: SemanticAdRuleRole,
680) -> std::result::Result<&EinsumExtensionOp, SemanticAdError> {
681    op.as_any()
682        .downcast_ref::<EinsumExtensionOp>()
683        .ok_or_else(|| SemanticAdError::Unsupported {
684            family_id: EINSUM_EXTENSION_FAMILY_ID,
685            role,
686            message: "einsum semantic AD received an incompatible payload".into(),
687        })
688}
689
690#[cfg(feature = "autodiff")]
691fn semantic_einsum_unsupported(message: impl Into<String>) -> SemanticAdError {
692    SemanticAdError::Unsupported {
693        family_id: EINSUM_EXTENSION_FAMILY_ID,
694        role: SemanticAdRuleRole::LinearTranspose,
695        message: message.into(),
696    }
697}
698
699#[cfg(feature = "autodiff")]
700fn vjp_plan_spec_for_active(
701    primal_plan: &EinsumPlanSpec,
702    input_count: usize,
703    active_idx: usize,
704) -> std::result::Result<EinsumPlanSpec, SemanticAdError> {
705    if active_idx >= input_count {
706        return Err(semantic_einsum_unsupported(format!(
707            "einsum VJP active input {active_idx} is outside {input_count} inputs"
708        )));
709    }
710
711    match primal_plan {
712        EinsumPlanSpec::Auto(options) => Ok(EinsumPlanSpec::Auto(options.clone())),
713        EinsumPlanSpec::LeftToRight => Ok(EinsumPlanSpec::LeftToRight),
714        EinsumPlanSpec::Path(path) => {
715            let pairs = jax_path_to_v1_pairs(path, input_count).map_err(|err| {
716                semantic_einsum_unsupported(format!(
717                    "failed to inherit einsum Path plan for VJP active input {active_idx}: {err}"
718                ))
719            })?;
720            derive_vjp_fixed_pairs(&pairs, input_count, active_idx).map(EinsumPlanSpec::FixedPairs)
721        }
722        EinsumPlanSpec::FixedPairs(pairs) => {
723            derive_vjp_fixed_pairs(pairs, input_count, active_idx).map(EinsumPlanSpec::FixedPairs)
724        }
725    }
726}
727
728#[cfg(feature = "autodiff")]
729fn derive_vjp_fixed_pairs(
730    primal_pairs: &[(usize, usize)],
731    input_count: usize,
732    active_idx: usize,
733) -> std::result::Result<Vec<(usize, usize)>, SemanticAdError> {
734    if input_count == 0 {
735        return Err(semantic_einsum_unsupported(
736            "einsum VJP cannot derive a plan for zero primal inputs",
737        ));
738    }
739    if active_idx >= input_count {
740        return Err(semantic_einsum_unsupported(format!(
741            "einsum VJP active input {active_idx} is outside {input_count} inputs"
742        )));
743    }
744    let required_steps = input_count.saturating_sub(1);
745    if primal_pairs.len() != required_steps {
746        return Err(semantic_einsum_unsupported(format!(
747            "einsum VJP cannot inherit explicit plan for active input {active_idx}: \
748             expected {required_steps} primal steps for {input_count} inputs, got {}",
749            primal_pairs.len()
750        )));
751    }
752    if input_count == 1 {
753        return Ok(Vec::new());
754    }
755
756    let children = fixed_pair_children(primal_pairs, input_count, active_idx)?;
757    let mut primal_to_vjp = vec![None; input_count];
758    let mut next_vjp_input = 1;
759    for (input_idx, slot) in primal_to_vjp.iter_mut().enumerate() {
760        if input_idx != active_idx {
761            *slot = Some(next_vjp_input);
762            next_vjp_input += 1;
763        }
764    }
765
766    let root = input_count + primal_pairs.len() - 1;
767    let mut pairs = Vec::with_capacity(required_steps);
768    let final_id = emit_vjp_adjoint(
769        root,
770        0,
771        &children,
772        input_count,
773        active_idx,
774        &primal_to_vjp,
775        &mut pairs,
776    )?;
777    let expected_final = input_count + pairs.len() - 1;
778    if final_id != expected_final || pairs.len() != required_steps {
779        return Err(semantic_einsum_unsupported(format!(
780            "einsum VJP plan derivation for active input {active_idx} produced an invalid \
781             tree: final id {final_id}, expected {expected_final}, steps {}",
782            pairs.len()
783        )));
784    }
785    Ok(pairs)
786}
787
788#[cfg(feature = "autodiff")]
789fn fixed_pair_children(
790    pairs: &[(usize, usize)],
791    input_count: usize,
792    active_idx: usize,
793) -> std::result::Result<Vec<Option<(usize, usize)>>, SemanticAdError> {
794    let mut live = vec![false; input_count + pairs.len()];
795    for slot in live.iter_mut().take(input_count) {
796        *slot = true;
797    }
798    let mut children = vec![None; input_count + pairs.len()];
799
800    for (step_idx, &(left, right)) in pairs.iter().enumerate() {
801        let next_idx = input_count + step_idx;
802        if left == right {
803            return Err(invalid_vjp_plan_error(
804                active_idx,
805                format!("pair ({left}, {right}) references the same operand"),
806            ));
807        }
808        if left >= next_idx || right >= next_idx {
809            return Err(invalid_vjp_plan_error(
810                active_idx,
811                format!("pair ({left}, {right}) references a non-existent operand"),
812            ));
813        }
814        if !live[left] || !live[right] {
815            return Err(invalid_vjp_plan_error(
816                active_idx,
817                format!("pair ({left}, {right}) references an operand that is no longer live"),
818            ));
819        }
820
821        live[left] = false;
822        live[right] = false;
823        live[next_idx] = true;
824        children[next_idx] = Some((left, right));
825    }
826
827    let live_count = live.iter().filter(|&&is_live| is_live).count();
828    if live_count != 1 {
829        return Err(invalid_vjp_plan_error(
830            active_idx,
831            format!("explicit plan leaves {live_count} live operands"),
832        ));
833    }
834
835    Ok(children)
836}
837
838#[cfg(feature = "autodiff")]
839fn emit_vjp_adjoint(
840    node: usize,
841    cotangent_id: usize,
842    children: &[Option<(usize, usize)>],
843    input_count: usize,
844    active_idx: usize,
845    primal_to_vjp: &[Option<usize>],
846    pairs: &mut Vec<(usize, usize)>,
847) -> std::result::Result<usize, SemanticAdError> {
848    if node < input_count {
849        return if node == active_idx {
850            Ok(cotangent_id)
851        } else {
852            Err(invalid_vjp_plan_error(
853                active_idx,
854                format!("adjoint walk reached inactive leaf {node}"),
855            ))
856        };
857    }
858
859    let (left, right) = children.get(node).and_then(|child| *child).ok_or_else(|| {
860        invalid_vjp_plan_error(active_idx, format!("missing children for node {node}"))
861    })?;
862    let left_has_active = subtree_contains_active(left, children, input_count, active_idx)?;
863    let right_has_active = subtree_contains_active(right, children, input_count, active_idx)?;
864    match (left_has_active, right_has_active) {
865        (true, false) => {
866            let sibling_id = emit_vjp_subtree(
867                right,
868                children,
869                input_count,
870                active_idx,
871                primal_to_vjp,
872                pairs,
873            )?;
874            let next = push_vjp_pair(cotangent_id, sibling_id, input_count, pairs);
875            emit_vjp_adjoint(
876                left,
877                next,
878                children,
879                input_count,
880                active_idx,
881                primal_to_vjp,
882                pairs,
883            )
884        }
885        (false, true) => {
886            let sibling_id = emit_vjp_subtree(
887                left,
888                children,
889                input_count,
890                active_idx,
891                primal_to_vjp,
892                pairs,
893            )?;
894            let next = push_vjp_pair(cotangent_id, sibling_id, input_count, pairs);
895            emit_vjp_adjoint(
896                right,
897                next,
898                children,
899                input_count,
900                active_idx,
901                primal_to_vjp,
902                pairs,
903            )
904        }
905        (true, true) => Err(invalid_vjp_plan_error(
906            active_idx,
907            format!("both children of node {node} contain the active input"),
908        )),
909        (false, false) => Err(invalid_vjp_plan_error(
910            active_idx,
911            format!("neither child of node {node} contains the active input"),
912        )),
913    }
914}
915
916#[cfg(feature = "autodiff")]
917fn emit_vjp_subtree(
918    node: usize,
919    children: &[Option<(usize, usize)>],
920    input_count: usize,
921    active_idx: usize,
922    primal_to_vjp: &[Option<usize>],
923    pairs: &mut Vec<(usize, usize)>,
924) -> std::result::Result<usize, SemanticAdError> {
925    if node < input_count {
926        return primal_to_vjp[node].ok_or_else(|| {
927            invalid_vjp_plan_error(
928                active_idx,
929                format!("sibling subtree unexpectedly reached active leaf {node}"),
930            )
931        });
932    }
933
934    let (left, right) = children.get(node).and_then(|child| *child).ok_or_else(|| {
935        invalid_vjp_plan_error(active_idx, format!("missing children for node {node}"))
936    })?;
937    let left_id = emit_vjp_subtree(
938        left,
939        children,
940        input_count,
941        active_idx,
942        primal_to_vjp,
943        pairs,
944    )?;
945    let right_id = emit_vjp_subtree(
946        right,
947        children,
948        input_count,
949        active_idx,
950        primal_to_vjp,
951        pairs,
952    )?;
953    Ok(push_vjp_pair(left_id, right_id, input_count, pairs))
954}
955
956#[cfg(feature = "autodiff")]
957fn push_vjp_pair(
958    left: usize,
959    right: usize,
960    n_vjp_inputs: usize,
961    pairs: &mut Vec<(usize, usize)>,
962) -> usize {
963    pairs.push((left, right));
964    n_vjp_inputs + pairs.len() - 1
965}
966
967#[cfg(feature = "autodiff")]
968fn subtree_contains_active(
969    node: usize,
970    children: &[Option<(usize, usize)>],
971    input_count: usize,
972    active_idx: usize,
973) -> std::result::Result<bool, SemanticAdError> {
974    if node < input_count {
975        return Ok(node == active_idx);
976    }
977    let (left, right) = children.get(node).and_then(|child| *child).ok_or_else(|| {
978        invalid_vjp_plan_error(active_idx, format!("missing children for node {node}"))
979    })?;
980    Ok(
981        subtree_contains_active(left, children, input_count, active_idx)?
982            || subtree_contains_active(right, children, input_count, active_idx)?,
983    )
984}
985
986#[cfg(feature = "autodiff")]
987fn invalid_vjp_plan_error(active_idx: usize, reason: String) -> SemanticAdError {
988    semantic_einsum_unsupported(format!(
989        "einsum VJP cannot inherit explicit plan for active input {active_idx}: {reason}"
990    ))
991}
992
993#[cfg(feature = "autodiff")]
994fn concrete_sym_shapes(shapes: &[Vec<SymDim>]) -> Option<Vec<Vec<usize>>> {
995    shapes
996        .iter()
997        .map(|shape| shape.iter().map(SymDim::constant_value).collect())
998        .collect()
999}
1000
1001define_extension_runtime! {
1002    runtime = EinsumRuntime,
1003    family_id = EINSUM_EXTENSION_FAMILY_ID,
1004    op_type = EinsumExtensionOp,
1005    execute = execute_einsum_extension,
1006    execute_reads = execute_einsum_extension_reads,
1007    execute_in_session = execute_einsum_extension_reads_in_session,
1008    session_supported = einsum_session_supported,
1009}
1010
1011fn execute_einsum_extension<B: TensorBackend + 'static>(
1012    op: &EinsumExtensionOp,
1013    inputs: &[&Tensor],
1014    ctx: &mut ExtensionExecutionContext<'_, B>,
1015) -> tenferro_tensor::Result<Vec<Tensor>> {
1016    if inputs.is_empty() {
1017        return Err(tenferro_tensor::Error::invalid_argument(
1018            "einsum_extension",
1019            "inputs",
1020            "einsum requires at least one input tensor",
1021        ));
1022    }
1023
1024    let shapes: Vec<Vec<usize>> = inputs
1025        .iter()
1026        .map(|tensor| tensor.shape().to_vec())
1027        .collect();
1028    let shape_refs: Vec<&[usize]> = shapes.iter().map(Vec::as_slice).collect();
1029    let subs = Subscripts::from(op.subscripts());
1030    let tree = cached_runtime_tree(ctx, op.subscripts(), op.plan_spec(), &shapes, || {
1031        resolve_plan_spec(op.plan_spec(), &subs, &shape_refs)
1032    })?;
1033
1034    let output = ctx
1035        .backend_mut()
1036        .with_backend_session(|exec| crate::eager::eager_einsum_exec(exec, inputs, &tree))?;
1037    Ok(vec![output])
1038}
1039
1040pub(crate) fn execute_einsum_extension_reads<B: TensorBackend + 'static>(
1041    op: &EinsumExtensionOp,
1042    inputs: &[TensorRead<'_>],
1043    ctx: &mut ExtensionExecutionContext<'_, B>,
1044) -> tenferro_tensor::Result<Vec<Tensor>> {
1045    if inputs
1046        .iter()
1047        .all(|input| matches!(input, TensorRead::Tensor(_)))
1048    {
1049        let input_refs: Vec<&Tensor> = inputs
1050            .iter()
1051            .map(|input| match input {
1052                TensorRead::Tensor(tensor) => *tensor,
1053                TensorRead::View(_) => unreachable!("view input filtered above"),
1054            })
1055            .collect();
1056        return execute_einsum_extension(op, &input_refs, ctx);
1057    }
1058
1059    if inputs.is_empty() {
1060        return Err(tenferro_tensor::Error::invalid_argument(
1061            "einsum_extension",
1062            "inputs",
1063            "einsum requires at least one input tensor",
1064        ));
1065    }
1066
1067    let shapes: Vec<Vec<usize>> = inputs.iter().map(|input| input.shape().to_vec()).collect();
1068    let shape_refs: Vec<&[usize]> = shapes.iter().map(Vec::as_slice).collect();
1069    let subs = Subscripts::from(op.subscripts());
1070    let tree = cached_runtime_tree(ctx, op.subscripts(), op.plan_spec(), &shapes, || {
1071        resolve_plan_spec(op.plan_spec(), &subs, &shape_refs)
1072    })?;
1073    let output = ctx
1074        .backend_mut()
1075        .with_backend_session(|exec| crate::eager::eager_einsum_exec_read(exec, inputs, &tree))?;
1076    Ok(vec![output])
1077}
1078
1079pub(crate) fn execute_einsum_extension_session_reads(
1080    op: &EinsumExtensionOp,
1081    inputs: &[TensorRead<'_>],
1082    ctx: &mut ExtensionExecutionContext<'_, dyn BackendSession + '_>,
1083) -> tenferro_tensor::Result<Vec<Tensor>> {
1084    if inputs.is_empty() {
1085        return Err(tenferro_tensor::Error::invalid_argument(
1086            "einsum_extension",
1087            "inputs",
1088            "einsum requires at least one input tensor",
1089        ));
1090    }
1091
1092    let shapes: Vec<Vec<usize>> = inputs.iter().map(|input| input.shape().to_vec()).collect();
1093    let shape_refs: Vec<&[usize]> = shapes.iter().map(Vec::as_slice).collect();
1094    let subs = Subscripts::from(op.subscripts());
1095    let tree = cached_runtime_tree(ctx, op.subscripts(), op.plan_spec(), &shapes, || {
1096        resolve_plan_spec(op.plan_spec(), &subs, &shape_refs)
1097    })?;
1098    let output = crate::eager::eager_einsum_exec_read(ctx.backend_mut(), inputs, &tree)?;
1099    Ok(vec![output])
1100}
1101
1102/// Adapter from the scheduler/`apply_eager` borrowed-session shape to the
1103/// eager session executor. Reuses the existing forward kernel; reimplementing
1104/// it here is explicitly out of scope for issue #1665.
1105fn execute_einsum_extension_reads_in_session(
1106    op: &EinsumExtensionOp,
1107    session: &mut dyn BackendSession,
1108    caches: &mut tenferro_runtime::ExtensionCacheStore,
1109    inputs: &[TensorRead<'_>],
1110) -> tenferro_tensor::Result<Vec<Tensor>> {
1111    let mut ctx = ExtensionExecutionContext::new(session, caches);
1112    execute_einsum_extension_session_reads(op, inputs, &mut ctx)
1113}
1114
1115fn einsum_session_supported<B: BackendSession + 'static>(_op: &EinsumExtensionOp) -> bool {
1116    // The session executor runs the same forward kernel on any backend session
1117    // below (the einsum execution only needs elementwise/dot session ops), so
1118    // CPU and CUDA sessions both qualify for `apply_eager`'s native path.
1119    let type_id = std::any::TypeId::of::<B>();
1120    type_id == std::any::TypeId::of::<tenferro_cpu::CpuBackend>() || {
1121        #[cfg(feature = "cuda")]
1122        {
1123            type_id == std::any::TypeId::of::<tenferro_gpu::cuda::CudaBackend>()
1124        }
1125        #[cfg(not(feature = "cuda"))]
1126        {
1127            false
1128        }
1129    }
1130}
1131
1132#[derive(Clone)]
1133struct RuntimeTreeCacheKeyData {
1134    subscripts: EinsumSubscripts,
1135    shapes: Vec<Vec<usize>>,
1136    plan_spec: EinsumPlanSpec,
1137}
1138
1139impl RuntimeTreeCacheKeyData {
1140    fn new(
1141        subscripts: &EinsumSubscripts,
1142        shapes: &[Vec<usize>],
1143        plan_spec: &EinsumPlanSpec,
1144    ) -> Self {
1145        Self {
1146            subscripts: subscripts.clone(),
1147            shapes: shapes.to_vec(),
1148            plan_spec: plan_spec.clone(),
1149        }
1150    }
1151
1152    fn matches_runtime_tree(
1153        &self,
1154        subscripts: &EinsumSubscripts,
1155        shapes: &[Vec<usize>],
1156        plan_spec: &EinsumPlanSpec,
1157    ) -> bool {
1158        self.subscripts == *subscripts
1159            && self.shapes.as_slice() == shapes
1160            && plan_specs_equal(&self.plan_spec, plan_spec)
1161    }
1162
1163    fn retained_bytes(&self) -> usize {
1164        saturating_sum([
1165            einsum_subscripts_retained_bytes(&self.subscripts),
1166            saturating_sum(self.shapes.iter().map(vec_retained_bytes)),
1167            plan_spec_retained_bytes(&self.plan_spec),
1168        ])
1169    }
1170}
1171
1172struct CachedRuntimeTree {
1173    key_data: RuntimeTreeCacheKeyData,
1174    tree: Arc<ContractionTree>,
1175}
1176
1177fn cached_runtime_tree<B: BackendSession + ?Sized>(
1178    ctx: &mut ExtensionExecutionContext<'_, B>,
1179    subscripts: &EinsumSubscripts,
1180    plan_spec: &EinsumPlanSpec,
1181    shapes: &[Vec<usize>],
1182    build: impl FnOnce() -> EinsumResult<ContractionTree>,
1183) -> tenferro_tensor::Result<Arc<ContractionTree>> {
1184    let plan_hash = plan_spec_hash(plan_spec);
1185    let key = ExtensionCacheKey::new(
1186        EINSUM_EXTENSION_FAMILY_ID,
1187        EINSUM_RUNTIME_PLANS_CACHE,
1188        runtime_tree_cache_discriminator(subscripts, shapes, plan_hash),
1189    );
1190    if let Some(cached) = ctx.caches_mut().get::<CachedRuntimeTree>(&key) {
1191        let key_data = &cached.key_data;
1192        if key_data.matches_runtime_tree(subscripts, shapes, plan_spec) {
1193            return Ok(Arc::clone(&cached.tree));
1194        }
1195    }
1196
1197    let tree = Arc::new(build().map_err(einsum_runtime_error)?);
1198    let key_data = RuntimeTreeCacheKeyData::new(subscripts, shapes, plan_spec);
1199    let retained_bytes = saturating_sum([
1200        key_data.retained_bytes(),
1201        tree.retained_bytes_for_cache_stats(),
1202    ]);
1203    ctx.caches_mut().put(
1204        key,
1205        CachedRuntimeTree {
1206            key_data,
1207            tree: Arc::clone(&tree),
1208        },
1209        retained_bytes,
1210    );
1211    Ok(tree)
1212}
1213
1214fn einsum_runtime_error(error: EinsumError) -> tenferro_tensor::Error {
1215    error.into_tensor_error("einsum_extension")
1216}
1217
1218fn runtime_tree_cache_discriminator(
1219    subscripts: &EinsumSubscripts,
1220    shapes: &[Vec<usize>],
1221    plan_hash: u64,
1222) -> u64 {
1223    let mut hasher = DefaultHasher::new();
1224    subscripts.hash(&mut hasher);
1225    shapes.hash(&mut hasher);
1226    plan_hash.hash(&mut hasher);
1227    hasher.finish()
1228}
1229
1230fn plan_spec_hash(plan_spec: &EinsumPlanSpec) -> u64 {
1231    let mut hasher = DefaultHasher::new();
1232    hash_einsum_plan_spec(plan_spec, &mut hasher);
1233    hasher.finish()
1234}
1235
1236fn plan_spec_retained_bytes(plan_spec: &EinsumPlanSpec) -> usize {
1237    match plan_spec {
1238        EinsumPlanSpec::Auto(options) => saturating_sum([
1239            std::mem::size_of::<EinsumPlanSpec>(),
1240            vec_retained_bytes(&options.betas),
1241        ]),
1242        EinsumPlanSpec::LeftToRight => std::mem::size_of::<EinsumPlanSpec>(),
1243        EinsumPlanSpec::Path(path) | EinsumPlanSpec::FixedPairs(path) => saturating_sum([
1244            std::mem::size_of::<EinsumPlanSpec>(),
1245            vec_retained_bytes(path),
1246        ]),
1247    }
1248}
1249
1250fn promote_dtypes(dtypes: impl IntoIterator<Item = DType>) -> DType {
1251    dtypes
1252        .into_iter()
1253        .reduce(tenferro_tensor::validate::promote_dtype)
1254        .unwrap_or(DType::F64)
1255}
1256
1257#[cfg(test)]
1258mod tests;