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}