Skip to main content

native_onnx/
api.rs

1//! Public compile, load, and inference APIs. Provider mechanics live in session.rs.
2use crate::engine::ModelFormat;
3use crate::session::ActiveSession;
4use crate::{
5    Backend, BackendSelection, CompileOptions, OnnxOptions, Result, Tensor, TensorSpec, TensorView,
6    TensorViewMut,
7};
8use crate::{engine, session};
9use anyhow::{Context, bail, ensure};
10use std::{
11    fs,
12    path::{Path, PathBuf},
13};
14
15/// ONNX input and output names, data types, and shapes.
16#[derive(Clone, Debug)]
17pub struct ModelInfo {
18    /// Required inputs, in model order.
19    pub inputs: Vec<TensorSpec>,
20    /// Produced outputs, in model order.
21    pub outputs: Vec<TensorSpec>,
22}
23
24/// A provider failure recorded before trying the next configured backend.
25#[derive(Clone, Debug)]
26pub struct FallbackEvent {
27    /// Name of the provider that failed.
28    pub backend: String,
29    /// Error message including its context chain.
30    pub error: String,
31}
32
33fn failure_report(events: &[FallbackEvent]) -> String {
34    events
35        .iter()
36        .map(|event| format!("  {}: {}", event.backend, event.error))
37        .collect::<Vec<_>>()
38        .join("\n")
39}
40
41/// Format produced by a successful compilation.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum CompiledFormat {
44    /// ONNX graph optimized for the selected provider; no embedded native context.
45    OptimizedOnnx,
46    /// ONNX wrapper with embedded, provider-specific compiled state.
47    EpContext,
48}
49
50/// Summary of an artifact compiled and validated for the explicitly requested target.
51#[derive(Clone, Debug)]
52pub struct CompileReport {
53    /// Provider that successfully compiled and validated the artifact.
54    pub backend: String,
55    /// Representation stored at the requested destination.
56    pub format: CompiledFormat,
57}
58
59/// A loaded ONNX model with one active ONNX Runtime session.
60///
61/// Adds provider fallback, model metadata, and reusable I/O buffers around the native
62/// session. Methods are synchronous; load one session inside each inference worker.
63///
64/// This type is neither `Send` nor `Sync`. Create, use, and drop it on the same
65/// thread. Independent workers can own graph-enabled sessions on the same GPU.
66/// Native setup, first-run capture and teardown are coordinated; subsequent
67/// inference/replay can run concurrently on separate session-owned streams.
68/// CUDA graph state in ONNX Runtime is per host thread, so moving a session after
69/// capture would require renewed capture coordination, even without concurrent calls.
70///
71/// Start with [`Self::inference`] for ordinary or dynamic inputs. For fixed tensor
72/// shapes, [`Self::input_mut`], [`Self::run`], and [`Self::output`] reuse storage.
73/// Public inputs and outputs always use CPU memory, including on GPU backends.
74pub struct OnnxSession {
75    path: PathBuf,
76    options: OnnxOptions,
77    model_info: ModelInfo,
78    active_session: Option<ActiveSession>,
79    fallback_events: Vec<FallbackEvent>,
80    is_ep_context: bool,
81}
82
83impl OnnxSession {
84    /// Load an ONNX graph or an EPContext engine file (detected from its contents).
85    /// EPContext files need a compatible configured provider; their original graph is not rebuilt.
86    /// The file extension does not determine the format. External ONNX tensor files
87    /// must be available relative to the model file when the model references them.
88    /// Native libraries must already be installed; see [runtime discovery](crate#native-runtime-discovery).
89    ///
90    /// # Errors
91    /// Returns an error for missing/incompatible libraries, invalid model/configuration,
92    /// unsupported dense tensor metadata, or when every selected provider fails.
93    /// [`BackendSelection::Auto`] may continue to a later candidate; inspect
94    /// [`Self::fallback_events`] and [`Self::backend`] after loading.
95    ///
96    /// # Example
97    /// ```no_run
98    /// use native_onnx::{OnnxOptions, OnnxSession};
99    /// let model = OnnxSession::load("model.onnx", OnnxOptions::cpu())?;
100    /// for input in &model.info().inputs {
101    ///     println!("{}: {:?} {:?}", input.name, input.dtype, input.shape);
102    /// }
103    /// # Ok::<(), native_onnx::Error>(())
104    /// ```
105    pub fn load(path: impl AsRef<Path>, options: OnnxOptions) -> Result<Self> {
106        Self::load_internal(path.as_ref(), options, None)
107    }
108
109    /// Compile for an explicit target and validate the saved model with representative inputs.
110    /// [`CompileOptions`] requires a target with its provider configuration. Any target
111    /// failure is returned directly; compilation never falls back to another provider.
112    /// The result reports an optimized ONNX graph or an embedded provider context.
113    /// Existing output files are never overwritten; correspondence checks are the caller's job.
114    ///
115    /// TensorRT compilation currently requires one exportable partition and produces
116    /// an ONNX EPContext containing its engine. CPU/CUDA produce optimized ONNX.
117    /// The exported artifact is reopened and executed using `inputs` before the
118    /// destination is created. TensorRT artifacts depend on compatible hardware and
119    /// native libraries; loading with different builder settings does not rebuild them.
120    ///
121    /// # Errors
122    /// Returns an error for an existing destination, a source that is already an
123    /// EPContext, invalid inputs/options, compilation or validation failure,
124    /// CUDA stream/capture errors, or file I/O failures. No partial destination is
125    /// retained on failure. Native compilation may use temporary disk space.
126    ///
127    /// Inference options with automatic backend selection cannot be used to compile:
128    /// ```compile_fail
129    /// use native_onnx::{OnnxOptions, OnnxSession};
130    /// OnnxSession::compile("model.onnx", "compiled.onnx", OnnxOptions::default(), &[]);
131    /// ```
132    pub fn compile(
133        source: impl AsRef<Path>,
134        destination: impl AsRef<Path>,
135        options: CompileOptions,
136        inputs: &[TensorView<'_>],
137    ) -> Result<CompileReport> {
138        let destination = destination.as_ref();
139        ensure!(!destination.exists(), "Compiled output already exists");
140        let options = options.into_runtime_options()?;
141        options.validate()?;
142        session::initialize()?;
143        let source = source.as_ref().canonicalize().context("ONNX source path")?;
144        ensure!(
145            matches!(engine::inspect(&source, &options)?.0, ModelFormat::Onnx),
146            "Compile requires an ONNX graph, not an existing EPContext"
147        );
148        let backend = options.backend.candidates()[0].name().to_owned();
149        let (bytes, format) = Self::compile_and_validate(&source, options, inputs)
150            .with_context(|| format!("Compile for {backend}"))?;
151        use std::io::Write;
152        let parent = destination
153            .parent()
154            .filter(|p| !p.as_os_str().is_empty())
155            .unwrap_or_else(|| Path::new("."));
156        let mut file = tempfile::Builder::new()
157            .prefix(".native-onnx-")
158            .tempfile_in(parent)?;
159        file.write_all(&bytes)?;
160        file.as_file().sync_all()?;
161        file.persist_noclobber(destination).map_err(|error| {
162            // Dropping PersistError's temporary file removes incomplete output.
163            anyhow::anyhow!(
164                "Publish compiled model {}: {}",
165                destination.display(),
166                error.error
167            )
168        })?;
169        Ok(CompileReport { backend, format })
170    }
171
172    /// Primary registered provider, not a claim that every node runs on that provider.
173    /// `Auto` permits node partitioning across providers. Returns `None` if an
174    /// inference failure exhausted all fallback candidates and left no active session.
175    pub fn backend(&self) -> Option<&str> {
176        self.active_session
177            .as_ref()
178            .map(|a| self.options.backend.candidates()[a.backend_index].name())
179    }
180    /// Whole-session provider failures recorded during loading or inference.
181    /// Entries are chronological and retain formatted native error context.
182    /// Successful node partitioning inside ORT does not add an event here.
183    pub fn fallback_events(&self) -> &[FallbackEvent] {
184        &self.fallback_events
185    }
186    /// Whether the active session has reusable buffers for fixed-shape tensor I/O.
187    /// All inputs and outputs must have known ranks and positive, fixed dimensions.
188    /// Custom providers use ordinary inference. This does not indicate whether
189    /// CUDA graphs are enabled, or whether every model operation runs on a GPU.
190    pub fn is_prepared(&self) -> bool {
191        self.active_session
192            .as_ref()
193            .is_some_and(|a| a.buffers().is_some())
194    }
195    /// Whether the selected session is configured for CUDA graph capture/replay.
196    /// Requires fixed, positive I/O, sequential execution and strict GPU placement
197    /// (or a compiled EPContext). Capture completes during the first successful run.
198    pub fn cuda_graph_enabled(&self) -> bool {
199        self.active_session
200            .as_ref()
201            .is_some_and(ActiveSession::graph_enabled)
202    }
203
204    /// Finish ONNX Runtime profiling and return the profile's file path.
205    /// Enable profiling with [`OnnxOptions::profiling`] before loading the model.
206    /// This ends profiling for the active native session. If fallback has occurred,
207    /// earlier sessions may have separate profile files under the configured prefix.
208    /// Native profiling or filesystem failures are returned with their context.
209    pub fn end_profiling(&mut self) -> Result<String> {
210        self.active_session
211            .as_mut()
212            .context("No active backend")?
213            .end_profiling()
214    }
215}
216
217impl OnnxSession {
218    /// Return the ONNX model input/output metadata.
219    /// Metadata reflects the active provider's resolved shapes. A failed fallback
220    /// leaves the last known metadata available even if [`Self::backend`] is `None`.
221    pub fn info(&self) -> &ModelInfo {
222        &self.model_info
223    }
224    /// Run inference from borrowed inputs and return owned, CPU-ready output tensors.
225    ///
226    /// Supply every input exactly once, matched by name; input order is arbitrary.
227    /// Outputs are in [`ModelInfo::outputs`] order and remain valid after the next
228    /// inference or after dropping the session. Fixed-shape models reuse internal
229    /// buffers but copy results into the returned tensors. Dynamic models use
230    /// ordinary ORT execution. No image preprocessing or output decoding is applied.
231    ///
232    /// # Errors
233    /// Names, element types, ranks, concrete dimensions, and buffer lengths must
234    /// match the model. Provider errors are returned directly in required mode;
235    /// automatic mode can retry the same inputs on a later backend. A native crash
236    /// cannot be converted to a Rust error.
237    pub fn inference(&mut self, inputs: &[TensorView<'_>]) -> Result<Vec<Tensor>> {
238        self.validate_inputs(inputs)?;
239        loop {
240            let active = self.active_session.as_mut().context("No active backend")?;
241            let result = active.inference(&self.model_info, inputs);
242            match result {
243                Ok(values) => {
244                    return Ok(values);
245                }
246                Err(e) => self.fallback(e)?,
247            }
248        }
249    }
250    /// Run inference into caller-owned output buffers. Fixed tensor I/O reuses prepared storage.
251    ///
252    /// Inputs and outputs are matched by name and must each cover their full model
253    /// interface without duplicates. Output shapes, types, and lengths must match
254    /// the actual results. The dynamic path first creates owned results and then
255    /// copies them; this method is not a guarantee of allocation-free execution.
256    ///
257    /// # Errors
258    /// Returns the input/provider errors of [`Self::inference`], or an output
259    /// name/shape/type/length mismatch. All output buffers are validated before
260    /// result data is copied into them.
261    pub fn inference_into(
262        &mut self,
263        inputs: &[TensorView<'_>],
264        outputs: &mut [TensorViewMut<'_>],
265    ) -> Result<()> {
266        self.validate_inputs(inputs)?;
267        if !self.is_prepared() {
268            return crate::tensor::copy_outputs(&self.inference(inputs)?, outputs);
269        }
270        ensure!(
271            outputs.len() == self.model_info.outputs.len(),
272            "Output count mismatch"
273        );
274        for (i, output) in outputs.iter().enumerate() {
275            ensure!(
276                !outputs[..i].iter().any(|x| x.name == output.name),
277                "Duplicate output {}",
278                output.name
279            );
280            let spec = self
281                .model_info
282                .outputs
283                .iter()
284                .find(|s| s.name == output.name)
285                .context("Unknown output")?;
286            ensure!(
287                output.data.dtype() == spec.dtype
288                    && spec.fixed_shape().as_deref() == Some(output.shape)
289                    && output.data.len() == crate::element_count(output.shape)?,
290                "Output shape/type/length mismatch"
291            );
292        }
293        loop {
294            if !self.is_prepared() {
295                return crate::tensor::copy_outputs(&self.inference(inputs)?, outputs);
296            }
297            for input in inputs {
298                self.input_mut(input.name)?.data.copy_from(input.data)?;
299            }
300            let active = self.active_session.as_mut().context("No active backend")?;
301            match active.run() {
302                Ok(()) => {
303                    break;
304                }
305                Err(error) => self.fallback(error)?,
306            }
307        }
308        for output in outputs {
309            output.data.copy_from(self.output(output.name)?.data)?;
310        }
311        Ok(())
312    }
313}
314
315impl OnnxSession {
316    /// Borrow a persistent input buffer for fixed-shape tensor I/O.
317    /// Inputs are initialized to zero when the session is loaded.
318    /// Borrowing an input invalidates the previous prepared outputs, even if no
319    /// elements are changed. The borrow must end before another mutable operation
320    /// on the session. Other input buffers retain their values until updated.
321    ///
322    /// # Errors
323    /// Returns an error for an unknown name, unavailable prepared buffers, or a
324    /// native tensor access failure. Use [`Self::inference`] for dynamic I/O.
325    pub fn input_mut(&mut self, name: &str) -> Result<TensorViewMut<'_>> {
326        let buffers = self
327            .active_session
328            .as_mut()
329            .and_then(ActiveSession::buffers_mut)
330            .context("Prepared buffers unavailable; use inference for dynamic I/O")?;
331        buffers.outputs_valid = false;
332        buffers
333            .input_tensors
334            .iter_mut()
335            .find(|s| s.name == name)
336            .context("Unknown input")?
337            .view_mut()
338    }
339    /// Borrow the last successful prepared output. The borrow expires before the next run.
340    /// The data is CPU-accessible even when inference ran on a GPU. To retain it,
341    /// call [`TensorView::to_owned`] before changing inputs or running again.
342    ///
343    /// # Errors
344    /// Returns an error before a successful run, after an input was borrowed for
345    /// mutation, after a failed run, for an unknown name, or without prepared buffers.
346    pub fn output(&self, name: &str) -> Result<TensorView<'_>> {
347        let buffers = self
348            .active_session
349            .as_ref()
350            .and_then(ActiveSession::buffers)
351            .context("Prepared buffers unavailable")?;
352        ensure!(
353            buffers.outputs_valid,
354            "No successful output for current input"
355        );
356        buffers
357            .output_tensors
358            .iter()
359            .find(|s| s.name == name)
360            .context("Unknown output")?
361            .view()
362    }
363    /// Run the fixed-shape model using its persistent input and output buffers.
364    /// Unchanged inputs retain their previous values. Successful results become
365    /// available through [`Self::output`]. This call completes GPU-to-host transfers
366    /// before returning; it does not launch background inference.
367    ///
368    /// # Errors
369    /// Returns an error without prepared buffers or on an unrecoverable provider
370    /// failure. In automatic mode, input data is preserved across fallback. If the
371    /// fallback provider has no prepared output API, use [`Self::inference`] instead.
372    pub fn run(&mut self) -> Result<()> {
373        let active = self.active_session.as_mut().context("No active backend")?;
374        match active.run() {
375            Ok(()) => Ok(()),
376            Err(error) => {
377                // Copy input only on failure, before dropping the failed pinned buffers.
378                let inputs = active
379                    .buffers()
380                    .context("Prepared buffers unavailable")?
381                    .input_tensors
382                    .iter()
383                    .map(|s| Ok(s.view()?.to_owned()))
384                    .collect::<Result<Vec<_>>>()?;
385                self.fallback(error)?;
386                let views = inputs.iter().map(Tensor::view).collect::<Vec<_>>();
387                self.inference(&views)?;
388                ensure!(
389                    self.is_prepared(),
390                    "Fallback ran successfully but has no prepared output; use inference for custom providers"
391                );
392                Ok(())
393            }
394        }
395    }
396}
397
398impl OnnxSession {
399    fn compile_and_validate(
400        source: &Path,
401        mut options: OnnxOptions,
402        inputs: &[TensorView<'_>],
403    ) -> Result<(Vec<u8>, CompiledFormat)> {
404        let directory = engine::BuildDirectory::new()?;
405        let bytes = if matches!(
406            options.backend,
407            BackendSelection::Require(Backend::TensorRt)
408        ) {
409            options.profiling = Some(directory.path().join("compile-profile"));
410            let mut model = Self::load_internal(source, options.clone(), Some(directory.path()))?;
411            model.inference(inputs)?;
412            let profile = model.end_profiling()?;
413            engine::export_from_profile(directory.path(), &model.model_info, Path::new(&profile))?
414        } else {
415            session::compile(source, &options)?
416        };
417        let staged = directory.path().join("compiled.onnx");
418        fs::write(&staged, &bytes)?;
419        let format = match engine::inspect(&staged, &options)?.0 {
420            ModelFormat::Onnx => CompiledFormat::OptimizedOnnx,
421            ModelFormat::EpContext => CompiledFormat::EpContext,
422        };
423        // Reopen and execute the actual exported bytes before publishing the destination.
424        options.profiling = None;
425        let mut model = Self::load(&staged, options)?;
426        model.inference(inputs)?;
427        Ok((bytes, format))
428    }
429
430    fn load_internal(
431        path: &Path,
432        options: OnnxOptions,
433        build_directory: Option<&Path>,
434    ) -> Result<Self> {
435        options.validate()?;
436        session::initialize()?;
437        let path = path.canonicalize().context("Model path")?;
438        let (format, info) = engine::inspect(&path, &options)?;
439        let mut model = Self {
440            path,
441            options,
442            model_info: info,
443            active_session: None,
444            fallback_events: Vec::new(),
445            is_ep_context: matches!(format, ModelFormat::EpContext),
446        };
447        if let ModelFormat::EpContext = format {
448            ensure!(
449                build_directory.is_none(),
450                "Compile requires the original ONNX graph"
451            );
452            let mut failures = Vec::new();
453            for index in 0..model.options.backend.candidates().len() {
454                match session::load(
455                    &model.path,
456                    &model.options,
457                    index,
458                    true,
459                    &model.model_info,
460                    None,
461                ) {
462                    Ok(active) => {
463                        model.active_session = Some(active);
464                        break;
465                    }
466                    Err(error) => failures.push(FallbackEvent {
467                        backend: model.options.backend.candidates()[index].name().into(),
468                        error: format!("{error:#}"),
469                    }),
470                }
471            }
472            ensure!(
473                model.active_session.is_some(),
474                "No configured backend could load the EPContext:\n{}",
475                failure_report(&failures)
476            );
477            model.fallback_events = failures;
478        } else {
479            model.activate(0, build_directory)?;
480        }
481        model.model_info = model
482            .active_session
483            .as_ref()
484            .context("No active backend")?
485            .info
486            .clone();
487        Ok(model)
488    }
489
490    fn activate(&mut self, start: usize, build_directory: Option<&Path>) -> Result<()> {
491        self.active_session = None; // Release failed GPU session before creating the fallback.
492        for index in start..self.options.backend.candidates().len() {
493            match session::load(
494                &self.path,
495                &self.options,
496                index,
497                false,
498                &self.model_info,
499                build_directory,
500            ) {
501                Ok(active) => {
502                    self.model_info = active.info.clone();
503                    self.active_session = Some(active);
504                    return Ok(());
505                }
506                Err(error) => self.fallback_events.push(FallbackEvent {
507                    backend: self.options.backend.candidates()[index].name().into(),
508                    error: format!("{error:#}"),
509                }),
510            }
511        }
512        bail!(
513            "All configured ONNX backends failed:\n{}",
514            failure_report(&self.fallback_events)
515        )
516    }
517
518    fn validate_inputs(&self, inputs: &[TensorView<'_>]) -> Result<()> {
519        ensure!(
520            inputs.len() == self.model_info.inputs.len(),
521            "Input count mismatch"
522        );
523        for (i, input) in inputs.iter().enumerate() {
524            ensure!(
525                !inputs[..i].iter().any(|x| x.name == input.name),
526                "Duplicate input {}",
527                input.name
528            );
529            self.model_info
530                .inputs
531                .iter()
532                .find(|s| s.name == input.name)
533                .with_context(|| format!("Unknown input {}", input.name))?
534                .validate(input)?;
535        }
536        Ok(())
537    }
538
539    fn fallback(&mut self, error: anyhow::Error) -> Result<()> {
540        let index = self
541            .active_session
542            .as_ref()
543            .context("No active backend")?
544            .backend_index;
545        if self.is_ep_context || matches!(self.options.backend, BackendSelection::Require(_)) {
546            return Err(error);
547        }
548        self.fallback_events.push(FallbackEvent {
549            backend: self.options.backend.candidates()[index].name().into(),
550            error: format!("{error:#}"),
551        });
552        self.activate(index + 1, None)
553    }
554}