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}