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    marker::PhantomData,
13    path::{Path, PathBuf},
14    rc::Rc,
15};
16
17/// ONNX input and output names, data types, and shapes.
18#[derive(Clone, Debug)]
19pub struct ModelInfo {
20    /// Required inputs, in model order.
21    pub inputs: Vec<TensorSpec>,
22    /// Produced outputs, in model order.
23    pub outputs: Vec<TensorSpec>,
24}
25
26/// A provider failure recorded before trying the next configured backend.
27#[derive(Clone, Debug)]
28pub struct FallbackEvent {
29    /// Name of the provider that failed.
30    pub backend: String,
31    /// Error message including its context chain.
32    pub error: String,
33}
34
35fn failure_report(events: &[FallbackEvent]) -> String {
36    events
37        .iter()
38        .map(|event| format!("  {}: {}", event.backend, event.error))
39        .collect::<Vec<_>>()
40        .join("\n")
41}
42
43/// Format produced by a successful compilation.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum CompiledFormat {
46    /// ONNX graph optimized for the selected provider; no embedded native context.
47    OptimizedOnnx,
48    /// ONNX wrapper with embedded, provider-specific compiled state.
49    EpContext,
50}
51
52/// Artifact compiled and validated for the explicitly requested target.
53#[derive(Clone, Debug)]
54pub struct Compilation {
55    /// Provider that successfully compiled and validated the artifact.
56    pub backend: String,
57    /// Representation stored at the requested destination.
58    pub format: CompiledFormat,
59}
60
61/// An ONNX model session with ordered provider fallback and reusable inference buffers.
62/// Methods are synchronous. Use one instance per concurrent inference worker.
63pub struct OnnxRuntime {
64    path: PathBuf,
65    options: OnnxOptions,
66    info: ModelInfo,
67    active: Option<ActiveSession>,
68    fallback_events: Vec<FallbackEvent>,
69    is_ep_context: bool,
70    _no_send_sync: PhantomData<Rc<()>>,
71}
72
73impl OnnxRuntime {
74    /// Load an ONNX graph or an EPContext engine file (detected from its contents).
75    /// EPContext files need a compatible configured provider; their original graph is not rebuilt.
76    pub fn load(path: impl AsRef<Path>, options: OnnxOptions) -> Result<Self> {
77        Self::load_internal(path.as_ref(), options, None)
78    }
79
80    /// Compile for an explicit target and validate the saved model with representative inputs.
81    /// [`CompileOptions`] requires a target with its provider configuration. Any target
82    /// failure is returned directly; compilation never falls back to another provider.
83    /// The result reports an optimized ONNX graph or an embedded provider context.
84    /// Existing output files are never overwritten; correspondence checks are the caller's job.
85    ///
86    /// Inference options with automatic backend selection cannot be used to compile:
87    /// ```compile_fail
88    /// use native_onnx::{OnnxOptions, OnnxRuntime};
89    /// OnnxRuntime::compile("model.onnx", "compiled.onnx", OnnxOptions::default(), &[]);
90    /// ```
91    pub fn compile(
92        source: impl AsRef<Path>,
93        destination: impl AsRef<Path>,
94        options: CompileOptions,
95        inputs: &[TensorView<'_>],
96    ) -> Result<Compilation> {
97        let destination = destination.as_ref();
98        ensure!(!destination.exists(), "Compiled output already exists");
99        let options = options.into_runtime_options()?;
100        options.validate()?;
101        session::initialize(&options)?;
102        let source = source.as_ref().canonicalize().context("ONNX source path")?;
103        ensure!(
104            matches!(engine::inspect(&source, &options)?.0, ModelFormat::Onnx),
105            "Compile requires an ONNX graph, not an existing EPContext"
106        );
107        let backend = options.backend.candidates()[0].name().to_owned();
108        let (bytes, format) = Self::compile_and_validate(&source, options, inputs)
109            .with_context(|| format!("Compile for {backend}"))?;
110        use std::io::Write;
111        let parent = destination
112            .parent()
113            .filter(|p| !p.as_os_str().is_empty())
114            .unwrap_or_else(|| Path::new("."));
115        let mut file = tempfile::Builder::new()
116            .prefix(".native-onnx-")
117            .tempfile_in(parent)?;
118        file.write_all(&bytes)?;
119        file.as_file().sync_all()?;
120        file.persist_noclobber(destination).map_err(|error| {
121            // Dropping PersistError's temporary file removes incomplete output.
122            anyhow::anyhow!(
123                "Publish compiled model {}: {}",
124                destination.display(),
125                error.error
126            )
127        })?;
128        Ok(Compilation { backend, format })
129    }
130
131    /// Primary registered provider, not a claim that every node runs on that provider.
132    pub fn backend(&self) -> Option<&str> {
133        self.active
134            .as_ref()
135            .map(|a| self.options.backend.candidates()[a.backend_index].name())
136    }
137    /// Whole-session provider failures recorded during loading or inference.
138    pub fn fallback_events(&self) -> &[FallbackEvent] {
139        &self.fallback_events
140    }
141    /// Whether the active session has reusable buffers for fixed-shape tensor I/O.
142    pub fn is_prepared(&self) -> bool {
143        self.active.as_ref().is_some_and(|a| a.buffers.is_some())
144    }
145    /// Finish ONNX Runtime profiling and return the profile's file path.
146    /// Enable profiling with [`OnnxOptions::profiling`] before loading the model.
147    pub fn end_profiling(&mut self) -> Result<String> {
148        Ok(self
149            .active
150            .as_mut()
151            .context("No active backend")?
152            .session
153            .end_profiling()?)
154    }
155}
156
157impl OnnxRuntime {
158    /// Return the ONNX model input/output metadata.
159    pub fn info(&self) -> &ModelInfo {
160        &self.info
161    }
162    /// Run inference from borrowed inputs and return owned, CPU-ready output tensors.
163    pub fn inference(&mut self, inputs: &[TensorView<'_>]) -> Result<Vec<Tensor>> {
164        self.validate_inputs(inputs)?;
165        loop {
166            let active = self.active.as_mut().context("No active backend")?;
167            let result = active.inference(&self.info, inputs);
168            match result {
169                Ok(values) => {
170                    return Ok(values);
171                }
172                Err(e) => self.fallback(e)?,
173            }
174        }
175    }
176    /// Run inference into caller-owned output buffers. Fixed tensor I/O reuses prepared storage.
177    pub fn inference_into(
178        &mut self,
179        inputs: &[TensorView<'_>],
180        outputs: &mut [TensorViewMut<'_>],
181    ) -> Result<()> {
182        self.validate_inputs(inputs)?;
183        if !self.is_prepared() {
184            return crate::tensor::copy_outputs(&self.inference(inputs)?, outputs);
185        }
186        ensure!(
187            outputs.len() == self.info.outputs.len(),
188            "Output count mismatch"
189        );
190        for (i, output) in outputs.iter().enumerate() {
191            ensure!(
192                !outputs[..i].iter().any(|x| x.name == output.name),
193                "Duplicate output {}",
194                output.name
195            );
196            let spec = self
197                .info
198                .outputs
199                .iter()
200                .find(|s| s.name == output.name)
201                .context("Unknown output")?;
202            ensure!(
203                output.data.dtype() == spec.dtype
204                    && spec.fixed_shape().as_deref() == Some(output.shape)
205                    && output.data.len() == crate::element_count(output.shape)?,
206                "Output shape/type/length mismatch"
207            );
208        }
209        loop {
210            if !self.is_prepared() {
211                return crate::tensor::copy_outputs(&self.inference(inputs)?, outputs);
212            }
213            for input in inputs {
214                self.input_mut(input.name)?.data.copy_from(input.data)?;
215            }
216            let active = self.active.as_mut().context("No active backend")?;
217            match active
218                .buffers
219                .as_mut()
220                .expect("prepared backend")
221                .run(&mut active.session)
222            {
223                Ok(()) => {
224                    break;
225                }
226                Err(error) => self.fallback(error)?,
227            }
228        }
229        for output in outputs {
230            output.data.copy_from(self.output(output.name)?.data)?;
231        }
232        Ok(())
233    }
234}
235
236impl OnnxRuntime {
237    /// Borrow a persistent input buffer for fixed-shape tensor I/O.
238    /// Inputs are initialized to zero when the session is loaded.
239    pub fn input_mut(&mut self, name: &str) -> Result<TensorViewMut<'_>> {
240        let buffers = self
241            .active
242            .as_mut()
243            .and_then(|a| a.buffers.as_mut())
244            .context("Prepared buffers unavailable; use inference for dynamic I/O")?;
245        buffers.outputs_valid = false;
246        buffers
247            .inputs
248            .iter_mut()
249            .find(|s| s.name == name)
250            .context("Unknown input")?
251            .view_mut()
252    }
253    /// Borrow the last successful prepared output. The borrow expires before the next run.
254    pub fn output(&self, name: &str) -> Result<TensorView<'_>> {
255        let buffers = self
256            .active
257            .as_ref()
258            .and_then(|a| a.buffers.as_ref())
259            .context("Prepared buffers unavailable")?;
260        ensure!(
261            buffers.outputs_valid,
262            "No successful output for current input"
263        );
264        buffers
265            .outputs
266            .iter()
267            .find(|s| s.name == name)
268            .context("Unknown output")?
269            .view()
270    }
271    /// Run the fixed-shape model using its persistent input and output buffers.
272    pub fn run(&mut self) -> Result<()> {
273        let active = self.active.as_mut().context("No active backend")?;
274        let buffers = active
275            .buffers
276            .as_mut()
277            .context("Prepared buffers unavailable")?;
278        match buffers.run(&mut active.session) {
279            Ok(()) => Ok(()),
280            Err(error) => {
281                // Copy input only on failure, before dropping the failed pinned buffers.
282                let inputs = buffers
283                    .inputs
284                    .iter()
285                    .map(|s| Ok(s.view()?.to_owned()))
286                    .collect::<Result<Vec<_>>>()?;
287                self.fallback(error)?;
288                let views = inputs.iter().map(Tensor::view).collect::<Vec<_>>();
289                self.inference(&views)?;
290                ensure!(
291                    self.is_prepared(),
292                    "Fallback ran successfully but has no prepared output; use inference for custom providers"
293                );
294                Ok(())
295            }
296        }
297    }
298}
299
300impl OnnxRuntime {
301    fn compile_and_validate(
302        source: &Path,
303        mut options: OnnxOptions,
304        inputs: &[TensorView<'_>],
305    ) -> Result<(Vec<u8>, CompiledFormat)> {
306        let directory = engine::BuildDirectory::new()?;
307        let bytes = if matches!(
308            options.backend,
309            BackendSelection::Require(Backend::TensorRt)
310        ) {
311            options.profiling = Some(directory.path().join("compile-profile"));
312            let mut model = Self::load_internal(source, options.clone(), Some(directory.path()))?;
313            model.inference(inputs)?;
314            let profile = model.end_profiling()?;
315            engine::export_from_profile(directory.path(), &model.info, Path::new(&profile))?
316        } else {
317            session::compile(source, &options)?
318        };
319        let staged = directory.path().join("compiled.onnx");
320        fs::write(&staged, &bytes)?;
321        let format = match engine::inspect(&staged, &options)?.0 {
322            ModelFormat::Onnx => CompiledFormat::OptimizedOnnx,
323            ModelFormat::EpContext => CompiledFormat::EpContext,
324        };
325        // Reopen and execute the actual exported bytes before publishing the destination.
326        options.profiling = None;
327        let mut model = Self::load(&staged, options)?;
328        model.inference(inputs)?;
329        Ok((bytes, format))
330    }
331
332    fn load_internal(
333        path: &Path,
334        options: OnnxOptions,
335        build_directory: Option<&Path>,
336    ) -> Result<Self> {
337        options.validate()?;
338        session::initialize(&options)?;
339        let path = path.canonicalize().context("Model path")?;
340        let (format, info) = engine::inspect(&path, &options)?;
341        let mut model = Self {
342            path,
343            options,
344            info,
345            active: None,
346            fallback_events: Vec::new(),
347            is_ep_context: matches!(format, ModelFormat::EpContext),
348            _no_send_sync: PhantomData,
349        };
350        if let ModelFormat::EpContext = format {
351            ensure!(
352                build_directory.is_none(),
353                "Compile requires the original ONNX graph"
354            );
355            let mut failures = Vec::new();
356            for index in 0..model.options.backend.candidates().len() {
357                match session::load(&model.path, &model.options, index, true, &model.info, None) {
358                    Ok(active) => {
359                        model.active = Some(active);
360                        break;
361                    }
362                    Err(error) => failures.push(FallbackEvent {
363                        backend: model.options.backend.candidates()[index].name().into(),
364                        error: format!("{error:#}"),
365                    }),
366                }
367            }
368            ensure!(
369                model.active.is_some(),
370                "No configured backend could load the EPContext:\n{}",
371                failure_report(&failures)
372            );
373            model.fallback_events = failures;
374        } else {
375            model.activate(0, build_directory)?;
376        }
377        model.info = model
378            .active
379            .as_ref()
380            .context("No active backend")?
381            .info
382            .clone();
383        Ok(model)
384    }
385
386    fn activate(&mut self, start: usize, build_directory: Option<&Path>) -> Result<()> {
387        self.active = None; // Release failed GPU session before creating the fallback.
388        for index in start..self.options.backend.candidates().len() {
389            match session::load(
390                &self.path,
391                &self.options,
392                index,
393                false,
394                &self.info,
395                build_directory,
396            ) {
397                Ok(active) => {
398                    self.info = active.info.clone();
399                    self.active = Some(active);
400                    return Ok(());
401                }
402                Err(error) => self.fallback_events.push(FallbackEvent {
403                    backend: self.options.backend.candidates()[index].name().into(),
404                    error: format!("{error:#}"),
405                }),
406            }
407        }
408        bail!(
409            "All configured ONNX backends failed:\n{}",
410            failure_report(&self.fallback_events)
411        )
412    }
413
414    fn validate_inputs(&self, inputs: &[TensorView<'_>]) -> Result<()> {
415        ensure!(
416            inputs.len() == self.info.inputs.len(),
417            "Input count mismatch"
418        );
419        for (i, input) in inputs.iter().enumerate() {
420            ensure!(
421                !inputs[..i].iter().any(|x| x.name == input.name),
422                "Duplicate input {}",
423                input.name
424            );
425            self.info
426                .inputs
427                .iter()
428                .find(|s| s.name == input.name)
429                .with_context(|| format!("Unknown input {}", input.name))?
430                .validate(input)?;
431        }
432        Ok(())
433    }
434
435    fn fallback(&mut self, error: anyhow::Error) -> Result<()> {
436        let index = self
437            .active
438            .as_ref()
439            .context("No active backend")?
440            .backend_index;
441        if self.is_ep_context || matches!(self.options.backend, BackendSelection::Require(_)) {
442            return Err(error);
443        }
444        self.fallback_events.push(FallbackEvent {
445            backend: self.options.backend.candidates()[index].name().into(),
446            error: format!("{error:#}"),
447        });
448        self.activate(index + 1, None)
449    }
450}