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}