1use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use onnx_runtime_ir::{DataType, DeviceType, Shape};
21
22pub use epcontext::{
23 CompiledPartition, EpContextPlacement, dump_session_ep_context, load_ep_context_nodes,
24};
25pub use onnx_runtime_loader::{EpContextDumpConfig, EpContextPartition, Model as EncoderModel};
26pub use error::SessionError;
27pub use executor::{CacheStats, ControlFlowStats};
28pub use tensor::{Tensor, cpu_allocator};
29
30mod epcontext;
31mod executor;
32mod sequence;
33mod tensor;
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum OpsetVersion {
38 Known(u64),
40 Undeclared,
42}
43
44impl std::fmt::Display for OpsetVersion {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Self::Known(version) => version.fmt(f),
48 Self::Undeclared => f.write_str("<undeclared>"),
49 }
50 }
51}
52
53mod error {
54 use super::OpsetVersion;
55
56 struct UnsupportedOpRemediation<'a> {
57 opset: OpsetVersion,
58 domain: &'a str,
59 }
60
61 impl std::fmt::Display for UnsupportedOpRemediation<'_> {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 if self.opset == OpsetVersion::Undeclared {
64 write!(
65 f,
66 "declare an opset_import for domain {:?} in the model, ",
67 self.domain
68 )?;
69 }
70 f.write_str(
71 "enable another EP that supports this operator and opset, convert or decompose \
72 the model operator, or file an nxrt issue with the model details",
73 )
74 }
75 }
76
77 fn unsupported_op_remediation(
78 opset: OpsetVersion,
79 domain: &str,
80 ) -> UnsupportedOpRemediation<'_> {
81 UnsupportedOpRemediation { opset, domain }
82 }
83
84 #[derive(Debug, thiserror::Error)]
86 pub enum SessionError {
87 #[error("session not initialized")]
88 NotInitialized,
89
90 #[error("input not found: {name}")]
91 InputNotFound { name: String },
92
93 #[error("unknown session option: {key}")]
94 UnknownOption { key: String },
95
96 #[error("invalid value {value:?} for session option {key:?}: expected one of {expected}")]
97 InvalidOption {
98 key: String,
99 value: String,
100 expected: String,
101 },
102
103 #[error("no model source: set a path or bytes on the builder")]
104 NoModelSource,
105
106 #[error(
107 "unsupported operator {domain}::{op_type}: no available execution provider has a \
108 kernel; node {node}, opset {opset}; consulted execution providers (priority order): \
109 {execution_providers}. To fix: {remediation}",
110 remediation = unsupported_op_remediation(*.opset, .domain)
111 )]
112 UnsupportedOp {
113 op_type: String,
114 domain: String,
115 node: String,
116 opset: OpsetVersion,
117 execution_providers: String,
118 },
119
120 #[error("value has a non-static (symbolic) shape and no binding to resolve it: {value}")]
121 DynamicShape { value: String },
122
123 #[error(
124 "symbol {symbol} bound to conflicting sizes {first} and {second} across bound inputs"
125 )]
126 SymbolConflict {
127 symbol: String,
128 first: usize,
129 second: usize,
130 },
131
132 #[error("input {name}: rank mismatch (graph declares rank {expected}, got {got})")]
133 RankMismatch {
134 name: String,
135 expected: usize,
136 got: usize,
137 },
138
139 #[error("no inferred shape for value {value} produced by op {op}")]
140 UnresolvedShape { value: String, op: String },
141
142 #[error("shape element count overflows usize for value {value} (dims {dims:?})")]
143 ShapeOverflow { value: String, dims: Vec<usize> },
144
145 #[error(
146 "op {op} produced {got} data-dependent output shape(s) but has {expected} output(s)"
147 )]
148 OutputShapeCountMismatch {
149 op: String,
150 expected: usize,
151 got: usize,
152 },
153
154 #[error("input {name}: dtype mismatch (expected {expected}, got {got})")]
155 DtypeMismatch {
156 name: String,
157 expected: String,
158 got: String,
159 },
160
161 #[error("input {name}: shape mismatch (expected {expected:?}, got {got:?})")]
162 ShapeMismatch {
163 name: String,
164 expected: Vec<usize>,
165 got: Vec<usize>,
166 },
167
168 #[error("internal executor error: {0}")]
169 Internal(String),
170
171 #[error(
172 "Sequence op {op}: {reason}"
173 )]
174 SequenceOp { op: String, reason: String },
175
176 #[error(
177 "EPContext reference node (main_context=0) has no matching primary \
178 (source={source_key:?}, partition_name={partition_name:?})"
179 )]
180 DanglingEpContext {
181 source_key: Option<String>,
182 partition_name: Option<String>,
183 },
184
185 #[error(transparent)]
186 Load(#[from] onnx_runtime_loader::LoaderError),
187
188 #[error(transparent)]
189 Ep(#[from] onnx_runtime_ep_api::EpError),
190
191 #[error(transparent)]
192 Ir(#[from] onnx_runtime_ir::IrError),
193
194 #[error(transparent)]
195 Graph(#[from] onnx_runtime_ir::GraphError),
196
197 #[error(transparent)]
198 Optimize(#[from] onnx_runtime_optimizer::OptimizerError),
199
200 #[error(transparent)]
201 ShapeInfer(#[from] onnx_runtime_shape_inference::ShapeInferError),
202 }
203
204 impl SessionError {
205 pub(crate) fn unsupported_op(
206 node: &onnx_runtime_ir::Node,
207 node_id: onnx_runtime_ir::NodeId,
208 opset: u64,
209 execution_providers: impl Into<String>,
210 ) -> Self {
211 let domain = if node.domain.is_empty() {
212 "ai.onnx".to_string()
213 } else {
214 node.domain.clone()
215 };
216 let node_display = if node.name.is_empty() {
217 format!("<unnamed node #{}>", node_id.0)
218 } else {
219 format!("{:?}", node.name)
220 };
221 let opset = if opset == u64::MAX {
222 OpsetVersion::Undeclared
223 } else {
224 OpsetVersion::Known(opset)
225 };
226 Self::UnsupportedOp {
227 op_type: node.op_type.clone(),
228 domain,
229 node: node_display,
230 opset,
231 execution_providers: execution_providers.into(),
232 }
233 }
234 }
235
236 pub type Result<T> = std::result::Result<T, SessionError>;
238}
239
240use error::Result;
241
242#[derive(Clone, Debug)]
244pub struct IoMeta {
245 pub name: String,
246 pub dtype: DataType,
247 pub shape: Shape,
248}
249
250#[derive(Clone, Debug, Default, PartialEq, Eq)]
253pub enum DevicePreference {
254 #[default]
256 Auto,
257 Cpu,
259 Gpu { index: Option<u32> },
261 Explicit { device_type: DeviceType, index: u32 },
263}
264
265#[derive(Clone, Debug)]
267pub struct WarmupShape {
268 pub input_name: String,
269 pub shape: Vec<usize>,
270}
271
272#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
284pub enum OptimizationLevel {
285 #[default]
287 None,
288 Basic,
292 All,
296}
297
298impl OptimizationLevel {
299 fn parse(value: &str) -> Option<Self> {
302 match value.trim().to_ascii_lowercase().as_str() {
303 "none" | "off" | "0" => Some(Self::None),
304 "basic" => Some(Self::Basic),
305 "all" => Some(Self::All),
306 _ => None,
307 }
308 }
309
310 fn passes(self) -> Vec<Box<dyn onnx_runtime_optimizer::OptimizationPass>> {
313 use onnx_runtime_optimizer::{ConstantFolding, DeadNodeElimination, OpFusion};
314 match self {
315 Self::None => Vec::new(),
316 Self::Basic => vec![Box::new(ConstantFolding), Box::new(DeadNodeElimination)],
317 Self::All => vec![
318 Box::new(ConstantFolding),
319 Box::new(DeadNodeElimination),
320 Box::new(OpFusion::new()),
321 ],
322 }
323 }
324}
325
326#[derive(Default)]
328pub struct SessionBuilder {
329 model_path: Option<PathBuf>,
330 model_bytes: Option<Vec<u8>>,
331 device: DevicePreference,
332 memory_limit: Option<usize>,
333 enable_profiling: bool,
334 warmup_shapes: Vec<WarmupShape>,
335 options: HashMap<String, String>,
336}
337
338impl SessionBuilder {
339 pub fn new() -> Self {
340 Self::default()
341 }
342
343 pub fn model(mut self, path: impl AsRef<Path>) -> Self {
344 self.model_path = Some(path.as_ref().to_path_buf());
345 self
346 }
347
348 pub fn model_bytes(mut self, bytes: &[u8]) -> Self {
349 self.model_bytes = Some(bytes.to_vec());
350 self
351 }
352
353 pub fn device(mut self, pref: DevicePreference) -> Self {
354 self.device = pref;
355 self
356 }
357
358 pub fn memory_limit(mut self, bytes: usize) -> Self {
359 self.memory_limit = Some(bytes);
360 self
361 }
362
363 pub fn profiling(mut self, enable: bool) -> Self {
364 self.enable_profiling = enable;
365 self
366 }
367
368 pub fn warmup(mut self, shapes: Vec<WarmupShape>) -> Self {
369 self.warmup_shapes = shapes;
370 self
371 }
372
373 pub fn option(mut self, key: &str, value: &str) -> Self {
392 self.options.insert(key.to_string(), value.to_string());
393 self
394 }
395
396 fn parse_options(
412 options: &HashMap<String, String>,
413 ) -> Result<(OptimizationLevel, EpContextDumpConfig)> {
414 let mut level = OptimizationLevel::None;
415 let mut ctx = EpContextDumpConfig::default();
416 for (key, value) in options {
417 match key.as_str() {
418 "optimization" => {
419 level = OptimizationLevel::parse(value).ok_or_else(|| {
420 SessionError::InvalidOption {
421 key: key.clone(),
422 value: value.clone(),
423 expected: "none, basic, all".to_string(),
424 }
425 })?;
426 }
427 "ep.context_enable" => {
428 ctx.enable = parse_bool_option(key, value)?;
429 }
430 "ep.context_file_path" => {
431 ctx.file_path = if value.trim().is_empty() {
433 None
434 } else {
435 Some(PathBuf::from(value))
436 };
437 }
438 "ep.context_embed_mode" => {
439 ctx.embed_mode = parse_embed_mode(key, value)?;
440 }
441 _ => return Err(SessionError::UnknownOption { key: key.clone() }),
444 }
445 }
446 Ok((level, ctx))
447 }
448
449 pub fn build(self) -> Result<InferenceSession> {
471 let (level, ep_context_config) = Self::parse_options(&self.options)?;
472
473 let _ = (self.device, self.memory_limit, self.enable_profiling);
476
477 let (mut graph, weights, model_dir) = match (self.model_path, self.model_bytes) {
478 (Some(path), _) => {
479 let model_dir = path
483 .parent()
484 .map(Path::to_path_buf)
485 .unwrap_or_else(|| PathBuf::from("."));
486 let (g, w) = onnx_runtime_loader::load_model_with_weights(path)?;
487 (g, w, model_dir)
488 }
489 (None, Some(bytes)) => {
490 let (g, w) = onnx_runtime_loader::load_model_bytes_with_weights(&bytes, ".")?;
491 (g, w, PathBuf::from("."))
492 }
493 (None, None) => return Err(SessionError::NoModelSource),
494 };
495
496 optimize_graph(&mut graph, level)?;
498
499 let mut session =
500 InferenceSession::from_parts(graph, weights, &model_dir, ep_context_config)?;
501 if !self.warmup_shapes.is_empty() {
502 session.warmup(&self.warmup_shapes)?;
503 }
504 Ok(session)
505 }
506}
507
508fn parse_bool_option(key: &str, value: &str) -> Result<bool> {
513 match value.trim().to_ascii_lowercase().as_str() {
514 "1" | "true" => Ok(true),
515 "0" | "false" => Ok(false),
516 _ => Err(SessionError::InvalidOption {
517 key: key.to_string(),
518 value: value.to_string(),
519 expected: "0, 1, true, false".to_string(),
520 }),
521 }
522}
523
524fn parse_embed_mode(key: &str, value: &str) -> Result<u8> {
529 match value.trim() {
530 "0" => Ok(0),
531 "1" => Ok(1),
532 _ => Err(SessionError::InvalidOption {
533 key: key.to_string(),
534 value: value.to_string(),
535 expected: "0, 1".to_string(),
536 }),
537 }
538}
539
540fn optimize_graph(graph: &mut onnx_runtime_ir::Graph, level: OptimizationLevel) -> Result<()> {
547 let passes = level.passes();
548 if passes.is_empty() {
549 return Ok(());
550 }
551
552 onnx_runtime_optimizer::run_passes(
553 graph,
554 &passes,
555 &onnx_runtime_optimizer::PassContext::new(),
556 )?;
557
558 graph
563 .opset_imports
564 .entry(onnx_runtime_optimizer::CONTRIB_DOMAIN.to_string())
565 .or_insert(1);
566
567 let registry = onnx_runtime_shape_inference::InferenceRegistry::default_registry();
570 let opset_imports = graph.opset_imports.clone();
571 registry.infer_graph(
572 graph,
573 &opset_imports,
574 onnx_runtime_shape_inference::MergePolicy::Permissive,
575 )?;
576
577 Ok(())
578}
579
580pub struct InferenceSession {
582 inputs: Vec<IoMeta>,
583 outputs: Vec<IoMeta>,
584 exec: executor::Executor,
585 ep_context_config: EpContextDumpConfig,
589}
590
591fn io_meta(graph: &onnx_runtime_ir::Graph, values: &[onnx_runtime_ir::ValueId]) -> Vec<IoMeta> {
592 values
593 .iter()
594 .map(|&vid| {
595 let v = graph.value(vid);
596 IoMeta {
597 name: v.name.clone().unwrap_or_default(),
598 dtype: v.dtype,
599 shape: v.shape.clone(),
600 }
601 })
602 .collect()
603}
604
605impl InferenceSession {
606 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
608 Self::builder().model(path).build()
609 }
610
611 pub fn load_bytes(bytes: &[u8]) -> Result<Self> {
613 Self::builder().model_bytes(bytes).build()
614 }
615
616 pub fn from_graph(graph: onnx_runtime_ir::Graph) -> Result<Self> {
622 Self::from_parts(
626 graph,
627 std::sync::Arc::new(onnx_runtime_loader::WeightStore::new()),
628 Path::new("."),
629 EpContextDumpConfig::default(),
630 )
631 }
632
633 fn from_parts(
634 graph: onnx_runtime_ir::Graph,
635 weights: std::sync::Arc<onnx_runtime_loader::WeightStore>,
636 model_dir: &Path,
637 ep_context_config: EpContextDumpConfig,
638 ) -> Result<Self> {
639 onnx_runtime_loader::validate_model(&graph)?;
640
641 let inputs = io_meta(&graph, &graph.inputs);
642 let outputs = io_meta(&graph, &graph.outputs);
643 let ep = executor::auto_detect_cpu_ep()?;
644
645 let eps: [(
653 onnx_runtime_ep_api::EpId,
654 &dyn onnx_runtime_ep_api::ExecutionProvider,
655 ); 1] = [(onnx_runtime_ep_api::EpId(0), ep.as_ref())];
656 epcontext::load_ep_context_nodes(&graph, model_dir, &eps)?;
657
658 let exec = executor::Executor::build(graph, weights, ep)?;
659 Ok(Self {
660 inputs,
661 outputs,
662 exec,
663 ep_context_config,
664 })
665 }
666
667 pub fn builder() -> SessionBuilder {
669 SessionBuilder::new()
670 }
671
672 pub fn run(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<Tensor>> {
674 self.exec.run(inputs)
675 }
676
677 pub fn inputs(&self) -> &[IoMeta] {
679 &self.inputs
680 }
681
682 pub fn outputs(&self) -> &[IoMeta] {
684 &self.outputs
685 }
686
687 pub fn cache_stats(&self) -> CacheStats {
689 self.exec.cache_stats()
690 }
691
692 pub fn control_flow_stats(&self) -> ControlFlowStats {
695 self.exec.control_flow_stats()
696 }
697
698 pub fn warmup(&mut self, shapes: &[WarmupShape]) -> Result<()> {
703 for ws in shapes {
704 if !self.inputs.iter().any(|m| m.name == ws.input_name) {
705 return Err(SessionError::InputNotFound {
706 name: ws.input_name.clone(),
707 });
708 }
709 }
710 self.exec.warmup()
711 }
712
713 pub fn ep_context_config(&self) -> &EpContextDumpConfig {
716 &self.ep_context_config
717 }
718
719 pub fn graph(&self) -> &onnx_runtime_ir::Graph {
729 self.exec.graph()
730 }
731
732 pub fn export_ep_context(
759 &self,
760 orig_path: &Path,
761 partitions: &[CompiledPartition],
762 ) -> Result<PathBuf> {
763 let model = EncoderModel::new(self.exec.graph()).with_weights(self.exec.weights().as_ref());
764 dump_session_ep_context(&model, orig_path, partitions, &self.ep_context_config)
765 }
766}
767
768pub fn load(path: impl AsRef<Path>) -> Result<InferenceSession> {
772 InferenceSession::load(path)
773}
774
775#[cfg(test)]
776mod option_tests {
777 use super::*;
778
779 fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
780 pairs
781 .iter()
782 .map(|(k, v)| (k.to_string(), v.to_string()))
783 .collect()
784 }
785
786 fn level_of(pairs: &[(&str, &str)]) -> Result<OptimizationLevel> {
787 SessionBuilder::parse_options(&opts(pairs)).map(|(level, _)| level)
788 }
789
790 fn ctx_of(pairs: &[(&str, &str)]) -> Result<EpContextDumpConfig> {
791 SessionBuilder::parse_options(&opts(pairs)).map(|(_, ctx)| ctx)
792 }
793
794 #[test]
795 fn optimization_defaults_to_none_when_unset() {
796 assert_eq!(level_of(&[]).unwrap(), OptimizationLevel::None);
797 }
798
799 #[test]
800 fn optimization_parses_known_values() {
801 for (v, want) in [
802 ("none", OptimizationLevel::None),
803 ("off", OptimizationLevel::None),
804 ("BASIC", OptimizationLevel::Basic),
805 ("All", OptimizationLevel::All),
806 ] {
807 assert_eq!(level_of(&[("optimization", v)]).unwrap(), want, "value {v:?}");
808 }
809 }
810
811 #[test]
812 fn unknown_option_key_is_rejected() {
813 let err = level_of(&[("optimisation", "all")]).unwrap_err();
814 assert!(matches!(err, SessionError::UnknownOption { key } if key == "optimisation"));
815 }
816
817 #[test]
818 fn invalid_optimization_value_is_rejected() {
819 let err = level_of(&[("optimization", "aggressive")]).unwrap_err();
820 assert!(matches!(
821 err,
822 SessionError::InvalidOption { key, value, .. } if key == "optimization" && value == "aggressive"
823 ));
824 }
825
826 #[test]
827 fn none_level_selects_no_passes() {
828 assert!(OptimizationLevel::None.passes().is_empty());
829 assert_eq!(OptimizationLevel::Basic.passes().len(), 2);
830 assert_eq!(OptimizationLevel::All.passes().len(), 3);
831 }
832
833 #[test]
836 fn ep_context_defaults_to_disabled() {
837 let ctx = ctx_of(&[]).unwrap();
838 assert_eq!(ctx, EpContextDumpConfig::default());
839 assert!(!ctx.enable);
840 assert_eq!(ctx.file_path, None);
841 assert_eq!(ctx.embed_mode, 1);
842 }
843
844 #[test]
845 fn ep_context_enable_parses_bool_forms() {
846 for (v, want) in [
847 ("1", true),
848 ("0", false),
849 ("true", true),
850 ("TRUE", true),
851 ("false", false),
852 ("False", false),
853 ] {
854 let ctx = ctx_of(&[("ep.context_enable", v)]).unwrap();
855 assert_eq!(ctx.enable, want, "value {v:?}");
856 }
857 }
858
859 #[test]
860 fn ep_context_enable_rejects_garbage() {
861 let err = ctx_of(&[("ep.context_enable", "yes")]).unwrap_err();
862 assert!(matches!(
863 err,
864 SessionError::InvalidOption { key, value, .. }
865 if key == "ep.context_enable" && value == "yes"
866 ));
867 }
868
869 #[test]
870 fn ep_context_file_path_parses_and_empty_clears() {
871 let ctx = ctx_of(&[("ep.context_file_path", "/out/net_ctx.onnx")]).unwrap();
872 assert_eq!(ctx.file_path, Some(PathBuf::from("/out/net_ctx.onnx")));
873
874 let ctx = ctx_of(&[("ep.context_file_path", "")]).unwrap();
876 assert_eq!(ctx.file_path, None);
877 }
878
879 #[test]
880 fn ep_context_embed_mode_parses_and_rejects() {
881 assert_eq!(ctx_of(&[("ep.context_embed_mode", "0")]).unwrap().embed_mode, 0);
882 assert_eq!(ctx_of(&[("ep.context_embed_mode", "1")]).unwrap().embed_mode, 1);
883
884 let err = ctx_of(&[("ep.context_embed_mode", "2")]).unwrap_err();
885 assert!(matches!(
886 err,
887 SessionError::InvalidOption { key, value, expected }
888 if key == "ep.context_embed_mode" && value == "2" && expected == "0, 1"
889 ));
890 }
891
892 #[test]
893 fn ep_context_options_combine_with_optimization() {
894 let (level, ctx) = SessionBuilder::parse_options(&opts(&[
895 ("optimization", "all"),
896 ("ep.context_enable", "1"),
897 ("ep.context_file_path", "/tmp/out_ctx.onnx"),
898 ("ep.context_embed_mode", "0"),
899 ]))
900 .unwrap();
901 assert_eq!(level, OptimizationLevel::All);
902 assert!(ctx.enable);
903 assert_eq!(ctx.file_path, Some(PathBuf::from("/tmp/out_ctx.onnx")));
904 assert_eq!(ctx.embed_mode, 0);
905 }
906}