Skip to main content

tenferro_ad/
extension.rs

1//! Eager AD support for out-of-tree extension primitives.
2
3use std::sync::Arc;
4
5use computegraph::GraphOperation;
6use tenferro_ops::std_tensor_op::StdTensorOp;
7use tenferro_runtime::{
8    Error, ErrorPhase, ExtensionModule, InputSignature, PrepareCapability, PrepareError, Result,
9    Runtime, RuntimeConfigError,
10};
11use tenferro_tensor::{Tensor, TensorRead, TensorValue};
12
13use crate::eager::{
14    eager_capture_active, eager_grad_recording_enabled, record_eager_outputs, EagerRuntime,
15    EagerTensor,
16};
17
18pub use tenferro_runtime::extension::{
19    apply, ExtensionCacheKey, ExtensionCacheLimits, ExtensionCacheSelector, ExtensionCacheStore,
20    ExtensionExecutionContext, ExtensionFamilyId, ExtensionOp,
21};
22
23/// Closed backend kind selected by the eager runtime owner for an extension.
24///
25/// # Examples
26///
27/// ```rust
28/// use tenferro_ad::extension::{EagerExtensionBackendKind, EagerExtensionTarget};
29/// use tenferro_runtime::EngineId;
30///
31/// let target = EagerExtensionTarget {
32///     engine_id: EngineId::new("example.engine")?,
33///     backend_kind: EagerExtensionBackendKind::Cpu,
34/// };
35/// assert!(matches!(
36///     target.backend_kind,
37///     EagerExtensionBackendKind::Cpu
38/// ));
39/// assert_eq!(target.engine_id.as_str(), "example.engine");
40/// # Ok::<(), Box<dyn std::error::Error>>(())
41/// ```
42#[doc(hidden)]
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum EagerExtensionBackendKind {
45    /// The eager runtime owns a CPU backend.
46    Cpu,
47    /// The eager runtime owns a CUDA backend.
48    #[cfg(feature = "cuda")]
49    Cuda,
50    /// The eager runtime owns a WebGPU backend.
51    #[cfg(feature = "webgpu")]
52    WebGpu,
53}
54
55/// Exact engine target selected by the eager runtime owner.
56///
57/// # Examples
58///
59/// ```rust
60/// use tenferro_ad::extension::{EagerExtensionBackendKind, EagerExtensionTarget};
61/// use tenferro_runtime::EngineId;
62///
63/// let target = EagerExtensionTarget {
64///     engine_id: EngineId::new("example.engine")?,
65///     backend_kind: EagerExtensionBackendKind::Cpu,
66/// };
67/// assert_eq!(target.backend_kind, EagerExtensionBackendKind::Cpu);
68/// # Ok::<(), Box<dyn std::error::Error>>(())
69/// ```
70#[doc(hidden)]
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct EagerExtensionTarget {
73    /// Exact runtime engine selected for this eager context.
74    pub engine_id: tenferro_runtime::EngineId,
75    /// Closed backend kind selected for this eager context.
76    pub backend_kind: EagerExtensionBackendKind,
77}
78
79#[cfg(test)]
80mod tests;
81
82/// Adopt an untracked eager tensor value produced by this runtime's backend.
83///
84/// This is a low-level extension contract for eager composite operations that
85/// execute through a lifetime-bound backend session and receive a lazy
86/// [`TensorValue`] from the backend. The value must have been produced for the
87/// same eager runtime; this helper intentionally does not register gradient
88/// metadata and must not be used for tracked outputs.
89///
90/// # Examples
91///
92/// ```rust
93/// use tenferro_ad::extension::adopt_untracked_eager_value;
94/// use tenferro_ad::EagerRuntime;
95/// use tenferro_cpu::CpuBackend;
96/// use tenferro_tensor::{Tensor, TensorValue};
97///
98/// let ctx = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
99/// let value = TensorValue::from_tensor(
100///     Tensor::from_vec_col_major(vec![1], vec![1.0_f64]).unwrap(),
101/// );
102/// let eager = adopt_untracked_eager_value(ctx, value)?;
103/// assert_eq!(eager.shape(), &[1]);
104/// assert!(!eager.tracks_grad());
105/// # Ok::<(), tenferro_ad::Error>(())
106/// ```
107/// # Errors
108///
109/// Returns [`Error::RuntimeState`] when the value cannot be registered in the
110/// supplied runtime, including an invalid or incompatible retained descriptor.
111#[must_use = "the adopted eager tensor carries the runtime value"]
112pub fn adopt_untracked_eager_value(
113    ctx: Arc<EagerRuntime>,
114    value: TensorValue,
115) -> Result<EagerTensor> {
116    EagerTensor::new_untracked_value_result(ctx, value)
117}
118
119/// Apply an extension op to eager AD tensors.
120///
121/// # Examples
122///
123/// ```rust
124/// use tenferro_ad::extension::apply_eager;
125/// use tenferro_ad::{EagerRuntime, EagerTensor};
126/// use tenferro_cpu::CpuBackend;
127/// use tenferro_tensor::Tensor;
128///
129/// let ctx = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
130/// let x = EagerTensor::from_tensor_in(
131///     Tensor::from_vec_col_major(vec![1], vec![1.0_f64]).unwrap(),
132///     ctx,
133/// ).unwrap();
134/// let _ = &x;
135/// let _apply = apply_eager;
136/// # Ok::<(), tenferro_ad::Error>(())
137/// ```
138/// # Errors
139///
140/// Returns `Error::Validation` with `InvalidArgument` when `inputs` is empty
141/// or its length differs from the extension's declared input count. Returns
142/// `Error::ContextMismatch` when tensors belong to different eager runtimes;
143/// backend, extension, and runtime-state failures retain their typed sources.
144pub fn apply_eager(op: Arc<dyn ExtensionOp>, inputs: &[&EagerTensor]) -> Result<Vec<EagerTensor>> {
145    let ctx = validate_eager_extension_inputs(op.as_ref(), inputs)?;
146    let std_op = StdTensorOp::Extension(op);
147    let input_reads: Vec<_> = inputs.iter().map(|tensor| tensor.tensor_read()).collect();
148    // Native immediate path: resolve the extension engine from the runtime
149    // snapshot, prepare the op, and execute through the prepared plan's
150    // scheduler-session executor when it is session-capable. This skips the
151    // SemanticProgram build/compile + run_compiled cost on every call.
152    if let Some(outputs) = try_prepared_eager_extension(&ctx, &std_op, &input_reads)? {
153        return finish_eager_extension_outputs(ctx, std_op, inputs, outputs);
154    }
155    let outputs = ctx.exec_outputs_read(&std_op, &input_reads)?;
156    finish_eager_extension_outputs(ctx, std_op, inputs, outputs)
157}
158
159/// Run one extension op through the snapshot-resolved native prepared path.
160///
161/// Returns `None` (so the caller falls back to the compiled-program path for
162/// the exact op and signature) when the eager runtime has no exact extension
163/// engine, the engine has no slot for the op's family and cannot prepare it,
164/// or the prepared plan has no scheduler-session executor. AD recording is
165/// never touched here; the caller owns `finish_eager_extension_outputs`.
166fn try_prepared_eager_extension(
167    ctx: &EagerRuntime,
168    op: &StdTensorOp,
169    input_reads: &[TensorRead<'_>],
170) -> Result<Option<Vec<Tensor>>> {
171    let StdTensorOp::Extension(ext) = op else {
172        return Ok(None);
173    };
174    // The eager runtime owns its exact extension engine; provider selection
175    // must not wander to a different engine that happens to be first in slot
176    // order. If the target is unavailable (e.g. the recording test backend),
177    // fall back to the compiled path.
178    let Ok(target) = ctx.eager_extension_target() else {
179        return Ok(None);
180    };
181    let signature = InputSignature::from_reads(input_reads).map_err(|source| {
182        Error::runtime_state_source("extension::apply_eager", ErrorPhase::Execution, source)
183    })?;
184    let PrepareCapability::Prepared(plan) =
185        ctx.runtime()
186            .prepare_extension_immediate(&target.engine_id, ext.as_ref(), &signature)?
187    else {
188        return Ok(None);
189    };
190    let Some(executor) = plan.executor() else {
191        return Ok(None);
192    };
193    let executor = Arc::clone(executor);
194    if executor.supports_session() {
195        // native-session: scheduler-owned session executor.
196        let outputs = ctx.with_extension_execution_context(|extension_ctx| {
197            let (session, caches) = extension_ctx.parts_mut();
198            executor.execute_in_session(session, caches, input_reads)
199        })??;
200        Ok(Some(outputs))
201    } else {
202        // native-context: mandatory `execute` bridge over the erased backend
203        // context (for out-of-tree prepared ops without a session executor).
204        let outputs = ctx.with_extension_erased_context(|erased, caches| {
205            executor.execute(erased, caches, input_reads)
206        })??;
207        Ok(Some(outputs))
208    }
209}
210
211/// Ensure an eager extension module is installed, then apply the op through
212/// the single [`apply_eager`] entry.
213///
214/// This thin wrapper is retained for module-owner eager call sites (linalg,
215/// einsum). Forward execution always routes through [`apply_eager`]'s native
216/// prepared path; this wrapper only owns the install-ensure step.
217///
218/// # Errors
219///
220/// Returns `Error::Validation` with `InvalidArgument` when `inputs` is empty
221/// or its length differs from the extension's declared input count. Returns
222/// `Error::ContextMismatch` when tensors belong to different eager runtimes;
223/// backend, extension, and runtime-state failures retain their typed sources.
224#[doc(hidden)]
225pub fn apply_eager_with_extension_session(
226    op: Arc<dyn ExtensionOp>,
227    inputs: &[&EagerTensor],
228    module: Arc<dyn ExtensionModule>,
229) -> Result<Vec<EagerTensor>> {
230    let ctx = validate_eager_extension_inputs(op.as_ref(), inputs)?;
231    ctx.install_extension_module(module)?;
232    apply_eager(op, inputs)
233}
234
235/// Apply an eager extension through the owner-selected engine and backend kind.
236///
237/// This narrow sibling-crate wrapper is used by FFT, whose module factory must
238/// follow the eager runtime's exact backend selection. Input, target, and
239/// ingress validation always run before `module_factory`; errors returned by
240/// the factory are propagated unchanged. The returned module is then passed to
241/// the owner-scoped ensure operation. Forward execution always routes through
242/// [`apply_eager`]'s native prepared path.
243///
244/// # Errors
245///
246/// Returns [`tenferro_runtime::Error::Validation`] with
247/// [`tenferro_tensor::ValidationError::InvalidArgument`] when `inputs` is empty
248/// or its length differs from the extension's declared input count. Returns
249/// [`tenferro_runtime::Error::ContextMismatch`] when tensors belong to
250/// different eager runtimes.
251///
252/// Returns [`tenferro_runtime::Error::RuntimeStateSource`] when the selected
253/// engine is missing through
254/// [`tenferro_runtime::RuntimeConfigError::MissingEngine`], an input has no
255/// ingress through [`tenferro_runtime::PrepareError::NoInputIngress`], or the
256/// cold/missing-registration ensure path rejects the module. Errors returned by
257/// `module_factory` are propagated unchanged. Session, cache, and output
258/// registration failures retain their typed runtime sources.
259#[doc(hidden)]
260pub fn apply_eager_with_targeted_extension_session(
261    op: Arc<dyn ExtensionOp>,
262    inputs: &[&EagerTensor],
263    module_factory: impl FnOnce(
264        EagerExtensionTarget,
265    ) -> tenferro_runtime::Result<Arc<dyn ExtensionModule>>,
266) -> Result<Vec<EagerTensor>> {
267    let ctx = validate_eager_extension_inputs(op.as_ref(), inputs)?;
268    let target = ctx.eager_extension_target()?;
269    let input_reads: Vec<_> = inputs.iter().map(|tensor| tensor.tensor_read()).collect();
270    validate_eager_extension_input_signature(&ctx, &target, &input_reads)?;
271    let module = module_factory(target.clone())?;
272    ctx.ensure_extension_module_for_engine(module, op.family_id(), &target.engine_id)?;
273    apply_eager(op, inputs)
274}
275
276pub(crate) fn validate_eager_extension_target(
277    runtime: &Runtime,
278    target: &EagerExtensionTarget,
279) -> Result<()> {
280    let snapshot = runtime.snapshot().map_err(|source| {
281        Error::runtime_state_source(
282            "extension::apply_eager_with_extension_session",
283            ErrorPhase::Execution,
284            source,
285        )
286    })?;
287    if snapshot.engine(&target.engine_id).is_none() {
288        return Err(Error::runtime_state_source(
289            "extension::apply_eager_with_extension_session",
290            ErrorPhase::Execution,
291            RuntimeConfigError::MissingEngine {
292                engine_id: target.engine_id.clone(),
293            },
294        ));
295    }
296    Ok(())
297}
298
299fn validate_eager_extension_input_signature(
300    ctx: &EagerRuntime,
301    target: &EagerExtensionTarget,
302    input_reads: &[TensorRead<'_>],
303) -> Result<()> {
304    let signature = InputSignature::from_reads(input_reads).map_err(|source| {
305        Error::runtime_state_source(
306            "extension::apply_eager_with_extension_session",
307            ErrorPhase::Execution,
308            source,
309        )
310    })?;
311    let snapshot = ctx.runtime().snapshot().map_err(|source| {
312        Error::runtime_state_source(
313            "extension::apply_eager_with_extension_session",
314            ErrorPhase::Execution,
315            source,
316        )
317    })?;
318    let engine = snapshot.engine(&target.engine_id).ok_or_else(|| {
319        Error::runtime_state_source(
320            "extension::apply_eager_with_extension_session",
321            ErrorPhase::Execution,
322            RuntimeConfigError::MissingEngine {
323                engine_id: target.engine_id.clone(),
324            },
325        )
326    })?;
327    for (input_index, entry) in signature.entries().iter().enumerate() {
328        if !engine.accepts_input_signature(entry) {
329            return Err(Error::runtime_state_source(
330                "extension::apply_eager_with_extension_session",
331                ErrorPhase::Execution,
332                PrepareError::NoInputIngress {
333                    input_index,
334                    placement: entry.placement().clone(),
335                },
336            ));
337        }
338    }
339    Ok(())
340}
341
342fn validate_eager_extension_inputs(
343    op: &dyn ExtensionOp,
344    inputs: &[&EagerTensor],
345) -> Result<Arc<EagerRuntime>> {
346    let Some(first) = inputs.first() else {
347        return Err(Error::invalid_argument(
348            "extension::apply_eager",
349            ErrorPhase::Execution,
350            "inputs",
351            "at least one input tensor is required",
352        ));
353    };
354    if inputs.len() != op.input_count() {
355        return Err(Error::invalid_argument(
356            "extension::apply_eager",
357            ErrorPhase::Execution,
358            "inputs",
359            format!(
360                "op family {:?} expects {} inputs, got {}",
361                op.family_id(),
362                op.input_count(),
363                inputs.len()
364            ),
365        ));
366    }
367
368    let ctx = Arc::clone(&first.ctx);
369    for tensor in inputs.iter().skip(1) {
370        if !first.same_context(tensor) {
371            return Err(Error::ContextMismatch {
372                lhs: first.ctx_id(),
373                rhs: tensor.ctx_id(),
374            });
375        }
376    }
377    Ok(ctx)
378}
379
380fn finish_eager_extension_outputs(
381    ctx: Arc<EagerRuntime>,
382    op: StdTensorOp,
383    inputs: &[&EagerTensor],
384    outputs: Vec<Tensor>,
385) -> Result<Vec<EagerTensor>> {
386    if outputs.len() != op.output_count() {
387        return Err(Error::Internal(format!(
388            "expected {} eager outputs for {:?}, got {}",
389            op.output_count(),
390            op,
391            outputs.len()
392        )));
393    }
394
395    if !eager_grad_recording_enabled()
396        || (!eager_capture_active() && !inputs.iter().any(|input| input.requires_grad))
397    {
398        return outputs
399            .into_iter()
400            .map(|output| EagerTensor::new_untracked_result(Arc::clone(&ctx), output))
401            .collect();
402    }
403
404    let output_refs: Vec<&Tensor> = outputs.iter().collect();
405    let recorded = record_eager_outputs(&op, &output_refs, inputs)?;
406    if recorded.traces.len() != outputs.len() {
407        return Err(Error::Internal(format!(
408            "expected {} eager traces for {:?}, got {}",
409            outputs.len(),
410            op,
411            recorded.traces.len()
412        )));
413    }
414    recorded
415        .traces
416        .into_iter()
417        .zip(recorded.semantic_traces)
418        .zip(outputs)
419        .map(|((trace, semantic_trace), output)| {
420            if trace.requires_grad {
421                EagerTensor::new_result_with_semantic_trace(
422                    Arc::clone(&ctx),
423                    trace.key,
424                    output,
425                    trace.requires_grad,
426                    trace.trace,
427                    semantic_trace,
428                )
429            } else {
430                EagerTensor::new_unregistered_result_with_semantic_trace(
431                    Arc::clone(&ctx),
432                    trace.key,
433                    output,
434                    trace.requires_grad,
435                    trace.trace,
436                    semantic_trace,
437                )
438            }
439        })
440        .collect()
441}
442
443/// Apply one standard tensor op eagerly and record it for AD when needed.
444///
445/// Extension crates use this when an extension-level eager operation expands
446/// into ordinary `StdTensorOp` nodes instead of a custom extension primitive.
447///
448/// # Errors
449///
450/// Returns [`tenferro_runtime::Error::TensorRuntime`] containing
451/// [`tenferro_tensor::ValidationError::InvalidArgument`] if an extension
452/// op is passed to this standard-op entry point. Returns
453/// [`tenferro_runtime::Error::ContextMismatch`] for tensors from different
454/// eager contexts and propagates typed tensor/backend/runtime-state failures
455/// from the selected eager context.
456pub fn apply_standard_op(op: StdTensorOp, inputs: &[&EagerTensor]) -> Result<EagerTensor> {
457    if matches!(op, StdTensorOp::Extension(_)) {
458        return Err(Error::invalid_argument(
459            "extension::apply_standard_op",
460            ErrorPhase::Execution,
461            "op",
462            "Extension ops must be passed to apply_eager",
463        ));
464    }
465    EagerTensor::nary_op(inputs, op)
466}