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/// Validate recording eligibility before a consuming extension mutation.
83/// This does not grant write authority: callers must still consume the input
84/// through `EagerTensor::into_value` and obtain an exclusive tensor borrow.
85///
86/// # Errors
87/// Returns `Error::RuntimeState` for gradient-tracked values, legacy AD traces,
88/// or active capture, including capture nested inside no_grad. Saved-value
89/// ownership must additionally pass the structural into_value check.
90/// Input-signature validation, module-factory and installation errors are
91/// propagated unchanged.
92///
93/// # Examples
94/// ```
95/// use tenferro_ad::{EagerRuntime, EagerTensor, Tensor};
96/// use tenferro_ad::extension::prepare_eager_in_place_input;
97/// let input = EagerTensor::requires_grad_in(
98///     Tensor::from_vec_col_major([1], vec![1.0_f64])?, EagerRuntime::new()?)?;
99/// let result = prepare_eager_in_place_input(&input, "example", |_| {
100///     panic!("tracked inputs must be rejected before module construction")
101/// });
102/// assert!(result.is_err());
103/// assert_eq!(input.shape(), &[1]);
104/// # Ok::<(), Box<dyn std::error::Error>>(())
105/// ```
106#[doc(hidden)]
107pub fn prepare_eager_in_place_input(
108    input: &EagerTensor,
109    family_id: &'static str,
110    module_factory: impl FnOnce(EagerExtensionTarget) -> Result<Arc<dyn ExtensionModule>>,
111) -> Result<()> {
112    if input.requires_grad || input.trace.is_some() || eager_capture_active() {
113        return Err(Error::runtime_state(
114            "eager in-place",
115            ErrorPhase::Execution,
116            "in-place execution requires an untracked value outside trace capture",
117        ));
118    }
119    let target = input.ctx.eager_extension_target()?;
120    validate_eager_extension_input_signature(&input.ctx, &target, &[input.tensor_read()])?;
121    let module = module_factory(target.clone())?;
122    input
123        .ctx
124        .ensure_extension_module_for_engine(module, family_id, &target.engine_id)?;
125    Ok(())
126}
127
128/// Adopt an untracked eager tensor value produced by this runtime's backend.
129///
130/// This is a low-level extension contract for eager composite operations that
131/// execute through a lifetime-bound backend session and receive a lazy
132/// [`TensorValue`] from the backend. The value must have been produced for the
133/// same eager runtime; this helper intentionally does not register gradient
134/// metadata and must not be used for tracked outputs.
135///
136/// # Examples
137///
138/// ```rust
139/// use tenferro_ad::extension::adopt_untracked_eager_value;
140/// use tenferro_ad::EagerRuntime;
141/// use tenferro_cpu::CpuBackend;
142/// use tenferro_tensor::{Tensor, TensorValue};
143///
144/// let ctx = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
145/// let value = TensorValue::from_tensor(
146///     Tensor::from_vec_col_major(vec![1], vec![1.0_f64]).unwrap(),
147/// );
148/// let eager = adopt_untracked_eager_value(ctx, value)?;
149/// assert_eq!(eager.shape(), &[1]);
150/// assert!(!eager.tracks_grad());
151/// # Ok::<(), tenferro_ad::Error>(())
152/// ```
153/// # Errors
154///
155/// Returns [`Error::RuntimeState`] when the value cannot be registered in the
156/// supplied runtime, including an invalid or incompatible retained descriptor.
157#[must_use = "the adopted eager tensor carries the runtime value"]
158pub fn adopt_untracked_eager_value(
159    ctx: Arc<EagerRuntime>,
160    value: TensorValue,
161) -> Result<EagerTensor> {
162    EagerTensor::new_untracked_value_result(ctx, value)
163}
164
165/// Apply an extension op to eager AD tensors.
166///
167/// # Examples
168///
169/// ```rust
170/// use tenferro_ad::extension::apply_eager;
171/// use tenferro_ad::{EagerRuntime, EagerTensor};
172/// use tenferro_cpu::CpuBackend;
173/// use tenferro_tensor::Tensor;
174///
175/// let ctx = EagerRuntime::with_cpu_backend(CpuBackend::new())?;
176/// let x = EagerTensor::from_tensor_in(
177///     Tensor::from_vec_col_major(vec![1], vec![1.0_f64]).unwrap(),
178///     ctx,
179/// ).unwrap();
180/// let _ = &x;
181/// let _apply = apply_eager;
182/// # Ok::<(), tenferro_ad::Error>(())
183/// ```
184/// # Errors
185///
186/// Returns `Error::Validation` with `InvalidArgument` when `inputs` is empty
187/// or its length differs from the extension's declared input count. Returns
188/// `Error::ContextMismatch` when tensors belong to different eager runtimes;
189/// backend, extension, and runtime-state failures retain their typed sources.
190pub fn apply_eager(op: Arc<dyn ExtensionOp>, inputs: &[&EagerTensor]) -> Result<Vec<EagerTensor>> {
191    let ctx = validate_eager_extension_inputs(op.as_ref(), inputs)?;
192    let std_op = StdTensorOp::Extension(op);
193    let input_reads: Vec<_> = inputs.iter().map(|tensor| tensor.tensor_read()).collect();
194    // Native immediate path: resolve the extension engine from the runtime
195    // snapshot, prepare the op, and execute through the prepared plan's
196    // scheduler-session executor when it is session-capable. This skips the
197    // SemanticProgram build/compile + run_compiled cost on every call.
198    if let Some(outputs) = try_prepared_eager_extension(&ctx, &std_op, &input_reads)? {
199        return finish_eager_extension_outputs(ctx, std_op, inputs, outputs);
200    }
201    let outputs = ctx.exec_outputs_read(&std_op, &input_reads)?;
202    finish_eager_extension_outputs(ctx, std_op, inputs, outputs)
203}
204
205/// Run one extension op through the snapshot-resolved native prepared path.
206///
207/// Returns `None` (so the caller falls back to the compiled-program path for
208/// the exact op and signature) when the eager runtime has no exact extension
209/// engine, the engine has no slot for the op's family and cannot prepare it,
210/// or the prepared plan has no scheduler-session executor. AD recording is
211/// never touched here; the caller owns `finish_eager_extension_outputs`.
212fn try_prepared_eager_extension(
213    ctx: &EagerRuntime,
214    op: &StdTensorOp,
215    input_reads: &[TensorRead<'_>],
216) -> Result<Option<Vec<Tensor>>> {
217    let StdTensorOp::Extension(ext) = op else {
218        return Ok(None);
219    };
220    // The eager runtime owns its exact extension engine; provider selection
221    // must not wander to a different engine that happens to be first in slot
222    // order. If the target is unavailable (e.g. the recording test backend),
223    // fall back to the compiled path.
224    let Ok(target) = ctx.eager_extension_target() else {
225        return Ok(None);
226    };
227    let signature = InputSignature::from_reads(input_reads).map_err(|source| {
228        Error::runtime_state_source("extension::apply_eager", ErrorPhase::Execution, source)
229    })?;
230    let PrepareCapability::Prepared(plan) =
231        ctx.runtime()
232            .prepare_extension_immediate(&target.engine_id, ext.as_ref(), &signature)?
233    else {
234        return Ok(None);
235    };
236    let Some(executor) = plan.executor() else {
237        return Ok(None);
238    };
239    let executor = Arc::clone(executor);
240    if executor.supports_session() {
241        // native-session: scheduler-owned session executor.
242        let outputs = ctx.with_extension_execution_context(|extension_ctx| {
243            let (session, caches) = extension_ctx.parts_mut();
244            executor.execute_in_session(session, caches, input_reads)
245        })??;
246        Ok(Some(outputs))
247    } else {
248        // native-context: mandatory `execute` bridge over the erased backend
249        // context (for out-of-tree prepared ops without a session executor).
250        let outputs = ctx.with_extension_erased_context(|erased, caches| {
251            executor.execute(erased, caches, input_reads)
252        })??;
253        Ok(Some(outputs))
254    }
255}
256
257/// Ensure an eager extension module is installed, then apply the op through
258/// the single [`apply_eager`] entry.
259///
260/// This thin wrapper is retained for module-owner eager call sites (linalg,
261/// einsum). Forward execution always routes through [`apply_eager`]'s native
262/// prepared path; this wrapper only owns the install-ensure step.
263///
264/// # Errors
265///
266/// Returns `Error::Validation` with `InvalidArgument` when `inputs` is empty
267/// or its length differs from the extension's declared input count. Returns
268/// `Error::ContextMismatch` when tensors belong to different eager runtimes;
269/// backend, extension, and runtime-state failures retain their typed sources.
270#[doc(hidden)]
271pub fn apply_eager_with_extension_session(
272    op: Arc<dyn ExtensionOp>,
273    inputs: &[&EagerTensor],
274    module: Arc<dyn ExtensionModule>,
275) -> Result<Vec<EagerTensor>> {
276    let ctx = validate_eager_extension_inputs(op.as_ref(), inputs)?;
277    ctx.install_extension_module(module)?;
278    apply_eager(op, inputs)
279}
280
281/// Apply an eager extension through the owner-selected engine and backend kind.
282///
283/// This narrow sibling-crate wrapper is used by FFT, whose module factory must
284/// follow the eager runtime's exact backend selection. Input, target, and
285/// ingress validation always run before `module_factory`; errors returned by
286/// the factory are propagated unchanged. The returned module is then passed to
287/// the owner-scoped ensure operation. Forward execution always routes through
288/// [`apply_eager`]'s native prepared path.
289///
290/// # Errors
291///
292/// Returns [`tenferro_runtime::Error::Validation`] with
293/// [`tenferro_tensor::ValidationError::InvalidArgument`] when `inputs` is empty
294/// or its length differs from the extension's declared input count. Returns
295/// [`tenferro_runtime::Error::ContextMismatch`] when tensors belong to
296/// different eager runtimes.
297///
298/// Returns [`tenferro_runtime::Error::RuntimeStateSource`] when the selected
299/// engine is missing through
300/// [`tenferro_runtime::RuntimeConfigError::MissingEngine`], an input has no
301/// ingress through [`tenferro_runtime::PrepareError::NoInputIngress`], or the
302/// cold/missing-registration ensure path rejects the module. Errors returned by
303/// `module_factory` are propagated unchanged. Session, cache, and output
304/// registration failures retain their typed runtime sources.
305#[doc(hidden)]
306pub fn apply_eager_with_targeted_extension_session(
307    op: Arc<dyn ExtensionOp>,
308    inputs: &[&EagerTensor],
309    module_factory: impl FnOnce(
310        EagerExtensionTarget,
311    ) -> tenferro_runtime::Result<Arc<dyn ExtensionModule>>,
312) -> Result<Vec<EagerTensor>> {
313    let ctx = validate_eager_extension_inputs(op.as_ref(), inputs)?;
314    let target = ctx.eager_extension_target()?;
315    let input_reads: Vec<_> = inputs.iter().map(|tensor| tensor.tensor_read()).collect();
316    validate_eager_extension_input_signature(&ctx, &target, &input_reads)?;
317    let module = module_factory(target.clone())?;
318    ctx.ensure_extension_module_for_engine(module, op.family_id(), &target.engine_id)?;
319    apply_eager(op, inputs)
320}
321
322pub(crate) fn validate_eager_extension_target(
323    runtime: &Runtime,
324    target: &EagerExtensionTarget,
325) -> Result<()> {
326    let snapshot = runtime.snapshot().map_err(|source| {
327        Error::runtime_state_source(
328            "extension::apply_eager_with_extension_session",
329            ErrorPhase::Execution,
330            source,
331        )
332    })?;
333    if snapshot.engine(&target.engine_id).is_none() {
334        return Err(Error::runtime_state_source(
335            "extension::apply_eager_with_extension_session",
336            ErrorPhase::Execution,
337            RuntimeConfigError::MissingEngine {
338                engine_id: target.engine_id.clone(),
339            },
340        ));
341    }
342    Ok(())
343}
344
345fn validate_eager_extension_input_signature(
346    ctx: &EagerRuntime,
347    target: &EagerExtensionTarget,
348    input_reads: &[TensorRead<'_>],
349) -> Result<()> {
350    let signature = InputSignature::from_reads(input_reads).map_err(|source| {
351        Error::runtime_state_source(
352            "extension::apply_eager_with_extension_session",
353            ErrorPhase::Execution,
354            source,
355        )
356    })?;
357    let snapshot = ctx.runtime().snapshot().map_err(|source| {
358        Error::runtime_state_source(
359            "extension::apply_eager_with_extension_session",
360            ErrorPhase::Execution,
361            source,
362        )
363    })?;
364    let engine = snapshot.engine(&target.engine_id).ok_or_else(|| {
365        Error::runtime_state_source(
366            "extension::apply_eager_with_extension_session",
367            ErrorPhase::Execution,
368            RuntimeConfigError::MissingEngine {
369                engine_id: target.engine_id.clone(),
370            },
371        )
372    })?;
373    for (input_index, entry) in signature.entries().iter().enumerate() {
374        if !engine.accepts_input_signature(entry) {
375            return Err(Error::runtime_state_source(
376                "extension::apply_eager_with_extension_session",
377                ErrorPhase::Execution,
378                PrepareError::NoInputIngress {
379                    input_index,
380                    placement: entry.placement().clone(),
381                },
382            ));
383        }
384    }
385    Ok(())
386}
387
388fn validate_eager_extension_inputs(
389    op: &dyn ExtensionOp,
390    inputs: &[&EagerTensor],
391) -> Result<Arc<EagerRuntime>> {
392    let Some(first) = inputs.first() else {
393        return Err(Error::invalid_argument(
394            "extension::apply_eager",
395            ErrorPhase::Execution,
396            "inputs",
397            "at least one input tensor is required",
398        ));
399    };
400    if inputs.len() != op.input_count() {
401        return Err(Error::invalid_argument(
402            "extension::apply_eager",
403            ErrorPhase::Execution,
404            "inputs",
405            format!(
406                "op family {:?} expects {} inputs, got {}",
407                op.family_id(),
408                op.input_count(),
409                inputs.len()
410            ),
411        ));
412    }
413
414    let ctx = Arc::clone(&first.ctx);
415    for tensor in inputs.iter().skip(1) {
416        if !first.same_context(tensor) {
417            return Err(Error::ContextMismatch {
418                lhs: first.ctx_id(),
419                rhs: tensor.ctx_id(),
420            });
421        }
422    }
423    Ok(ctx)
424}
425
426fn finish_eager_extension_outputs(
427    ctx: Arc<EagerRuntime>,
428    op: StdTensorOp,
429    inputs: &[&EagerTensor],
430    outputs: Vec<Tensor>,
431) -> Result<Vec<EagerTensor>> {
432    if outputs.len() != op.output_count() {
433        return Err(Error::Internal(format!(
434            "expected {} eager outputs for {:?}, got {}",
435            op.output_count(),
436            op,
437            outputs.len()
438        )));
439    }
440
441    if !eager_grad_recording_enabled()
442        || (!eager_capture_active() && !inputs.iter().any(|input| input.requires_grad))
443    {
444        return outputs
445            .into_iter()
446            .map(|output| EagerTensor::new_untracked_result(Arc::clone(&ctx), output))
447            .collect();
448    }
449
450    let output_refs: Vec<&Tensor> = outputs.iter().collect();
451    let recorded = record_eager_outputs(&op, &output_refs, inputs)?;
452    if recorded.traces.len() != outputs.len() {
453        return Err(Error::Internal(format!(
454            "expected {} eager traces for {:?}, got {}",
455            outputs.len(),
456            op,
457            recorded.traces.len()
458        )));
459    }
460    recorded
461        .traces
462        .into_iter()
463        .zip(recorded.semantic_traces)
464        .zip(outputs)
465        .map(|((trace, semantic_trace), output)| {
466            if trace.requires_grad {
467                EagerTensor::new_result_with_semantic_trace(
468                    Arc::clone(&ctx),
469                    trace.key,
470                    output,
471                    trace.requires_grad,
472                    trace.trace,
473                    semantic_trace,
474                )
475            } else {
476                EagerTensor::new_unregistered_result_with_semantic_trace(
477                    Arc::clone(&ctx),
478                    trace.key,
479                    output,
480                    trace.requires_grad,
481                    trace.trace,
482                    semantic_trace,
483                )
484            }
485        })
486        .collect()
487}
488
489/// Apply one standard tensor op eagerly and record it for AD when needed.
490///
491/// Extension crates use this when an extension-level eager operation expands
492/// into ordinary `StdTensorOp` nodes instead of a custom extension primitive.
493///
494/// # Errors
495///
496/// Returns [`tenferro_runtime::Error::TensorRuntime`] containing
497/// [`tenferro_tensor::ValidationError::InvalidArgument`] if an extension
498/// op is passed to this standard-op entry point. Returns
499/// [`tenferro_runtime::Error::ContextMismatch`] for tensors from different
500/// eager contexts and propagates typed tensor/backend/runtime-state failures
501/// from the selected eager context.
502pub fn apply_standard_op(op: StdTensorOp, inputs: &[&EagerTensor]) -> Result<EagerTensor> {
503    if matches!(op, StdTensorOp::Extension(_)) {
504        return Err(Error::invalid_argument(
505            "extension::apply_standard_op",
506            ErrorPhase::Execution,
507            "op",
508            "Extension ops must be passed to apply_eager",
509        ));
510    }
511    EagerTensor::nary_op(inputs, op)
512}