use std::collections::HashMap;
use std::path::{Path, PathBuf};
use onnx_runtime_ir::{DataType, DeviceType, Shape};
pub use epcontext::{
CompiledPartition, EpContextPlacement, dump_session_ep_context, load_ep_context_nodes,
};
pub use onnx_runtime_loader::{EpContextDumpConfig, EpContextPartition, Model as EncoderModel};
pub use error::SessionError;
pub use executor::{CacheStats, ControlFlowStats};
pub use tensor::{Tensor, cpu_allocator};
mod epcontext;
mod executor;
mod sequence;
mod tensor;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OpsetVersion {
Known(u64),
Undeclared,
}
impl std::fmt::Display for OpsetVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Known(version) => version.fmt(f),
Self::Undeclared => f.write_str("<undeclared>"),
}
}
}
mod error {
use super::OpsetVersion;
struct UnsupportedOpRemediation<'a> {
opset: OpsetVersion,
domain: &'a str,
}
impl std::fmt::Display for UnsupportedOpRemediation<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.opset == OpsetVersion::Undeclared {
write!(
f,
"declare an opset_import for domain {:?} in the model, ",
self.domain
)?;
}
f.write_str(
"enable another EP that supports this operator and opset, convert or decompose \
the model operator, or file an nxrt issue with the model details",
)
}
}
fn unsupported_op_remediation(
opset: OpsetVersion,
domain: &str,
) -> UnsupportedOpRemediation<'_> {
UnsupportedOpRemediation { opset, domain }
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("session not initialized")]
NotInitialized,
#[error("input not found: {name}")]
InputNotFound { name: String },
#[error("unknown session option: {key}")]
UnknownOption { key: String },
#[error("invalid value {value:?} for session option {key:?}: expected one of {expected}")]
InvalidOption {
key: String,
value: String,
expected: String,
},
#[error("no model source: set a path or bytes on the builder")]
NoModelSource,
#[error(
"unsupported operator {domain}::{op_type}: no available execution provider has a \
kernel; node {node}, opset {opset}; consulted execution providers (priority order): \
{execution_providers}. To fix: {remediation}",
remediation = unsupported_op_remediation(*.opset, .domain)
)]
UnsupportedOp {
op_type: String,
domain: String,
node: String,
opset: OpsetVersion,
execution_providers: String,
},
#[error("value has a non-static (symbolic) shape and no binding to resolve it: {value}")]
DynamicShape { value: String },
#[error(
"symbol {symbol} bound to conflicting sizes {first} and {second} across bound inputs"
)]
SymbolConflict {
symbol: String,
first: usize,
second: usize,
},
#[error("input {name}: rank mismatch (graph declares rank {expected}, got {got})")]
RankMismatch {
name: String,
expected: usize,
got: usize,
},
#[error("no inferred shape for value {value} produced by op {op}")]
UnresolvedShape { value: String, op: String },
#[error("shape element count overflows usize for value {value} (dims {dims:?})")]
ShapeOverflow { value: String, dims: Vec<usize> },
#[error(
"op {op} produced {got} data-dependent output shape(s) but has {expected} output(s)"
)]
OutputShapeCountMismatch {
op: String,
expected: usize,
got: usize,
},
#[error("input {name}: dtype mismatch (expected {expected}, got {got})")]
DtypeMismatch {
name: String,
expected: String,
got: String,
},
#[error("input {name}: shape mismatch (expected {expected:?}, got {got:?})")]
ShapeMismatch {
name: String,
expected: Vec<usize>,
got: Vec<usize>,
},
#[error("internal executor error: {0}")]
Internal(String),
#[error(
"Sequence op {op}: {reason}"
)]
SequenceOp { op: String, reason: String },
#[error(
"EPContext reference node (main_context=0) has no matching primary \
(source={source_key:?}, partition_name={partition_name:?})"
)]
DanglingEpContext {
source_key: Option<String>,
partition_name: Option<String>,
},
#[error(transparent)]
Load(#[from] onnx_runtime_loader::LoaderError),
#[error(transparent)]
Ep(#[from] onnx_runtime_ep_api::EpError),
#[error(transparent)]
Ir(#[from] onnx_runtime_ir::IrError),
#[error(transparent)]
Graph(#[from] onnx_runtime_ir::GraphError),
#[error(transparent)]
Optimize(#[from] onnx_runtime_optimizer::OptimizerError),
#[error(transparent)]
ShapeInfer(#[from] onnx_runtime_shape_inference::ShapeInferError),
}
impl SessionError {
pub(crate) fn unsupported_op(
node: &onnx_runtime_ir::Node,
node_id: onnx_runtime_ir::NodeId,
opset: u64,
execution_providers: impl Into<String>,
) -> Self {
let domain = if node.domain.is_empty() {
"ai.onnx".to_string()
} else {
node.domain.clone()
};
let node_display = if node.name.is_empty() {
format!("<unnamed node #{}>", node_id.0)
} else {
format!("{:?}", node.name)
};
let opset = if opset == u64::MAX {
OpsetVersion::Undeclared
} else {
OpsetVersion::Known(opset)
};
Self::UnsupportedOp {
op_type: node.op_type.clone(),
domain,
node: node_display,
opset,
execution_providers: execution_providers.into(),
}
}
}
pub type Result<T> = std::result::Result<T, SessionError>;
}
use error::Result;
#[derive(Clone, Debug)]
pub struct IoMeta {
pub name: String,
pub dtype: DataType,
pub shape: Shape,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum DevicePreference {
#[default]
Auto,
Cpu,
Gpu { index: Option<u32> },
Explicit { device_type: DeviceType, index: u32 },
}
#[derive(Clone, Debug)]
pub struct WarmupShape {
pub input_name: String,
pub shape: Vec<usize>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OptimizationLevel {
#[default]
None,
Basic,
All,
}
impl OptimizationLevel {
fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" | "off" | "0" => Some(Self::None),
"basic" => Some(Self::Basic),
"all" => Some(Self::All),
_ => None,
}
}
fn passes(self) -> Vec<Box<dyn onnx_runtime_optimizer::OptimizationPass>> {
use onnx_runtime_optimizer::{ConstantFolding, DeadNodeElimination, OpFusion};
match self {
Self::None => Vec::new(),
Self::Basic => vec![Box::new(ConstantFolding), Box::new(DeadNodeElimination)],
Self::All => vec![
Box::new(ConstantFolding),
Box::new(DeadNodeElimination),
Box::new(OpFusion::new()),
],
}
}
}
#[derive(Default)]
pub struct SessionBuilder {
model_path: Option<PathBuf>,
model_bytes: Option<Vec<u8>>,
device: DevicePreference,
memory_limit: Option<usize>,
enable_profiling: bool,
warmup_shapes: Vec<WarmupShape>,
options: HashMap<String, String>,
}
impl SessionBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn model(mut self, path: impl AsRef<Path>) -> Self {
self.model_path = Some(path.as_ref().to_path_buf());
self
}
pub fn model_bytes(mut self, bytes: &[u8]) -> Self {
self.model_bytes = Some(bytes.to_vec());
self
}
pub fn device(mut self, pref: DevicePreference) -> Self {
self.device = pref;
self
}
pub fn memory_limit(mut self, bytes: usize) -> Self {
self.memory_limit = Some(bytes);
self
}
pub fn profiling(mut self, enable: bool) -> Self {
self.enable_profiling = enable;
self
}
pub fn warmup(mut self, shapes: Vec<WarmupShape>) -> Self {
self.warmup_shapes = shapes;
self
}
pub fn option(mut self, key: &str, value: &str) -> Self {
self.options.insert(key.to_string(), value.to_string());
self
}
fn parse_options(
options: &HashMap<String, String>,
) -> Result<(OptimizationLevel, EpContextDumpConfig)> {
let mut level = OptimizationLevel::None;
let mut ctx = EpContextDumpConfig::default();
for (key, value) in options {
match key.as_str() {
"optimization" => {
level = OptimizationLevel::parse(value).ok_or_else(|| {
SessionError::InvalidOption {
key: key.clone(),
value: value.clone(),
expected: "none, basic, all".to_string(),
}
})?;
}
"ep.context_enable" => {
ctx.enable = parse_bool_option(key, value)?;
}
"ep.context_file_path" => {
ctx.file_path = if value.trim().is_empty() {
None
} else {
Some(PathBuf::from(value))
};
}
"ep.context_embed_mode" => {
ctx.embed_mode = parse_embed_mode(key, value)?;
}
_ => return Err(SessionError::UnknownOption { key: key.clone() }),
}
}
Ok((level, ctx))
}
pub fn build(self) -> Result<InferenceSession> {
let (level, ep_context_config) = Self::parse_options(&self.options)?;
let _ = (self.device, self.memory_limit, self.enable_profiling);
let (mut graph, weights, model_dir) = match (self.model_path, self.model_bytes) {
(Some(path), _) => {
let model_dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let (g, w) = onnx_runtime_loader::load_model_with_weights(path)?;
(g, w, model_dir)
}
(None, Some(bytes)) => {
let (g, w) = onnx_runtime_loader::load_model_bytes_with_weights(&bytes, ".")?;
(g, w, PathBuf::from("."))
}
(None, None) => return Err(SessionError::NoModelSource),
};
optimize_graph(&mut graph, level)?;
let mut session =
InferenceSession::from_parts(graph, weights, &model_dir, ep_context_config)?;
if !self.warmup_shapes.is_empty() {
session.warmup(&self.warmup_shapes)?;
}
Ok(session)
}
}
fn parse_bool_option(key: &str, value: &str) -> Result<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" => Ok(true),
"0" | "false" => Ok(false),
_ => Err(SessionError::InvalidOption {
key: key.to_string(),
value: value.to_string(),
expected: "0, 1, true, false".to_string(),
}),
}
}
fn parse_embed_mode(key: &str, value: &str) -> Result<u8> {
match value.trim() {
"0" => Ok(0),
"1" => Ok(1),
_ => Err(SessionError::InvalidOption {
key: key.to_string(),
value: value.to_string(),
expected: "0, 1".to_string(),
}),
}
}
fn optimize_graph(graph: &mut onnx_runtime_ir::Graph, level: OptimizationLevel) -> Result<()> {
let passes = level.passes();
if passes.is_empty() {
return Ok(());
}
onnx_runtime_optimizer::run_passes(
graph,
&passes,
&onnx_runtime_optimizer::PassContext::new(),
)?;
graph
.opset_imports
.entry(onnx_runtime_optimizer::CONTRIB_DOMAIN.to_string())
.or_insert(1);
let registry = onnx_runtime_shape_inference::InferenceRegistry::default_registry();
let opset_imports = graph.opset_imports.clone();
registry.infer_graph(
graph,
&opset_imports,
onnx_runtime_shape_inference::MergePolicy::Permissive,
)?;
Ok(())
}
pub struct InferenceSession {
inputs: Vec<IoMeta>,
outputs: Vec<IoMeta>,
exec: executor::Executor,
ep_context_config: EpContextDumpConfig,
}
fn io_meta(graph: &onnx_runtime_ir::Graph, values: &[onnx_runtime_ir::ValueId]) -> Vec<IoMeta> {
values
.iter()
.map(|&vid| {
let v = graph.value(vid);
IoMeta {
name: v.name.clone().unwrap_or_default(),
dtype: v.dtype,
shape: v.shape.clone(),
}
})
.collect()
}
impl InferenceSession {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
Self::builder().model(path).build()
}
pub fn load_bytes(bytes: &[u8]) -> Result<Self> {
Self::builder().model_bytes(bytes).build()
}
pub fn from_graph(graph: onnx_runtime_ir::Graph) -> Result<Self> {
Self::from_parts(
graph,
std::sync::Arc::new(onnx_runtime_loader::WeightStore::new()),
Path::new("."),
EpContextDumpConfig::default(),
)
}
fn from_parts(
graph: onnx_runtime_ir::Graph,
weights: std::sync::Arc<onnx_runtime_loader::WeightStore>,
model_dir: &Path,
ep_context_config: EpContextDumpConfig,
) -> Result<Self> {
onnx_runtime_loader::validate_model(&graph)?;
let inputs = io_meta(&graph, &graph.inputs);
let outputs = io_meta(&graph, &graph.outputs);
let ep = executor::auto_detect_cpu_ep()?;
let eps: [(
onnx_runtime_ep_api::EpId,
&dyn onnx_runtime_ep_api::ExecutionProvider,
); 1] = [(onnx_runtime_ep_api::EpId(0), ep.as_ref())];
epcontext::load_ep_context_nodes(&graph, model_dir, &eps)?;
let exec = executor::Executor::build(graph, weights, ep)?;
Ok(Self {
inputs,
outputs,
exec,
ep_context_config,
})
}
pub fn builder() -> SessionBuilder {
SessionBuilder::new()
}
pub fn run(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<Tensor>> {
self.exec.run(inputs)
}
pub fn inputs(&self) -> &[IoMeta] {
&self.inputs
}
pub fn outputs(&self) -> &[IoMeta] {
&self.outputs
}
pub fn cache_stats(&self) -> CacheStats {
self.exec.cache_stats()
}
pub fn control_flow_stats(&self) -> ControlFlowStats {
self.exec.control_flow_stats()
}
pub fn warmup(&mut self, shapes: &[WarmupShape]) -> Result<()> {
for ws in shapes {
if !self.inputs.iter().any(|m| m.name == ws.input_name) {
return Err(SessionError::InputNotFound {
name: ws.input_name.clone(),
});
}
}
self.exec.warmup()
}
pub fn ep_context_config(&self) -> &EpContextDumpConfig {
&self.ep_context_config
}
pub fn graph(&self) -> &onnx_runtime_ir::Graph {
self.exec.graph()
}
pub fn export_ep_context(
&self,
orig_path: &Path,
partitions: &[CompiledPartition],
) -> Result<PathBuf> {
let model = EncoderModel::new(self.exec.graph()).with_weights(self.exec.weights().as_ref());
dump_session_ep_context(&model, orig_path, partitions, &self.ep_context_config)
}
}
pub fn load(path: impl AsRef<Path>) -> Result<InferenceSession> {
InferenceSession::load(path)
}
#[cfg(test)]
mod option_tests {
use super::*;
fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn level_of(pairs: &[(&str, &str)]) -> Result<OptimizationLevel> {
SessionBuilder::parse_options(&opts(pairs)).map(|(level, _)| level)
}
fn ctx_of(pairs: &[(&str, &str)]) -> Result<EpContextDumpConfig> {
SessionBuilder::parse_options(&opts(pairs)).map(|(_, ctx)| ctx)
}
#[test]
fn optimization_defaults_to_none_when_unset() {
assert_eq!(level_of(&[]).unwrap(), OptimizationLevel::None);
}
#[test]
fn optimization_parses_known_values() {
for (v, want) in [
("none", OptimizationLevel::None),
("off", OptimizationLevel::None),
("BASIC", OptimizationLevel::Basic),
("All", OptimizationLevel::All),
] {
assert_eq!(level_of(&[("optimization", v)]).unwrap(), want, "value {v:?}");
}
}
#[test]
fn unknown_option_key_is_rejected() {
let err = level_of(&[("optimisation", "all")]).unwrap_err();
assert!(matches!(err, SessionError::UnknownOption { key } if key == "optimisation"));
}
#[test]
fn invalid_optimization_value_is_rejected() {
let err = level_of(&[("optimization", "aggressive")]).unwrap_err();
assert!(matches!(
err,
SessionError::InvalidOption { key, value, .. } if key == "optimization" && value == "aggressive"
));
}
#[test]
fn none_level_selects_no_passes() {
assert!(OptimizationLevel::None.passes().is_empty());
assert_eq!(OptimizationLevel::Basic.passes().len(), 2);
assert_eq!(OptimizationLevel::All.passes().len(), 3);
}
#[test]
fn ep_context_defaults_to_disabled() {
let ctx = ctx_of(&[]).unwrap();
assert_eq!(ctx, EpContextDumpConfig::default());
assert!(!ctx.enable);
assert_eq!(ctx.file_path, None);
assert_eq!(ctx.embed_mode, 1);
}
#[test]
fn ep_context_enable_parses_bool_forms() {
for (v, want) in [
("1", true),
("0", false),
("true", true),
("TRUE", true),
("false", false),
("False", false),
] {
let ctx = ctx_of(&[("ep.context_enable", v)]).unwrap();
assert_eq!(ctx.enable, want, "value {v:?}");
}
}
#[test]
fn ep_context_enable_rejects_garbage() {
let err = ctx_of(&[("ep.context_enable", "yes")]).unwrap_err();
assert!(matches!(
err,
SessionError::InvalidOption { key, value, .. }
if key == "ep.context_enable" && value == "yes"
));
}
#[test]
fn ep_context_file_path_parses_and_empty_clears() {
let ctx = ctx_of(&[("ep.context_file_path", "/out/net_ctx.onnx")]).unwrap();
assert_eq!(ctx.file_path, Some(PathBuf::from("/out/net_ctx.onnx")));
let ctx = ctx_of(&[("ep.context_file_path", "")]).unwrap();
assert_eq!(ctx.file_path, None);
}
#[test]
fn ep_context_embed_mode_parses_and_rejects() {
assert_eq!(ctx_of(&[("ep.context_embed_mode", "0")]).unwrap().embed_mode, 0);
assert_eq!(ctx_of(&[("ep.context_embed_mode", "1")]).unwrap().embed_mode, 1);
let err = ctx_of(&[("ep.context_embed_mode", "2")]).unwrap_err();
assert!(matches!(
err,
SessionError::InvalidOption { key, value, expected }
if key == "ep.context_embed_mode" && value == "2" && expected == "0, 1"
));
}
#[test]
fn ep_context_options_combine_with_optimization() {
let (level, ctx) = SessionBuilder::parse_options(&opts(&[
("optimization", "all"),
("ep.context_enable", "1"),
("ep.context_file_path", "/tmp/out_ctx.onnx"),
("ep.context_embed_mode", "0"),
]))
.unwrap();
assert_eq!(level, OptimizationLevel::All);
assert!(ctx.enable);
assert_eq!(ctx.file_path, Some(PathBuf::from("/tmp/out_ctx.onnx")));
assert_eq!(ctx.embed_mode, 0);
}
}