use std::path::Path;
use ndarray::{Array2, ArrayView2, ArrayView3, ArrayViewD, Ix2, Ix3, Ix4};
use ort::{
inputs,
session::{
Session,
builder::{GraphOptimizationLevel, SessionBuilder},
},
value::{TensorRef, ValueType},
};
use crate::{
config::RuntimeConfig,
error::{RapidOcrError, Result},
model_verify::VerifiedModel,
runtime::contracts::{ModelIoProbe, TensorSpec},
runtime::provider::{
ProviderResolution, ResolvedExecutionProvider, resolve_execution_providers,
},
};
pub const DIRECT_ML_MAX_OPSET: u32 = 20;
pub fn require_direct_ml_opset(opset: Option<u32>, model_path: &Path) -> Result<()> {
match opset {
Some(opset) if opset > DIRECT_ML_MAX_OPSET => Err(RapidOcrError::Config(format!(
"model {} declares ONNX opset {opset}, but the DirectML execution provider accepts at \
most opset {DIRECT_ML_MAX_OPSET}; re-export the model or use the CPU/CUDA provider",
model_path.display()
))),
_ => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DimensionOverride {
pub name: String,
pub size: i64,
}
impl DimensionOverride {
pub fn new(name: impl Into<String>, size: i64) -> Self {
Self {
name: name.into(),
size,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionBuildPolicy {
pub memory_pattern: bool,
pub parallel_execution: bool,
pub must_be_explicit: bool,
}
impl SessionBuildPolicy {
pub const ORT_DEFAULTS: Self = Self {
memory_pattern: true,
parallel_execution: false,
must_be_explicit: false,
};
pub fn for_provider(selected_ep: ResolvedExecutionProvider) -> Self {
match selected_ep {
ResolvedExecutionProvider::DirectMl => Self {
memory_pattern: false,
parallel_execution: false,
must_be_explicit: true,
},
ResolvedExecutionProvider::Cpu | ResolvedExecutionProvider::Cuda => Self::ORT_DEFAULTS,
}
}
pub fn required_options(&self) -> Vec<SessionBuildOption> {
if !self.must_be_explicit {
return Vec::new();
}
vec![
SessionBuildOption::MemoryPattern(self.memory_pattern),
SessionBuildOption::ParallelExecution(self.parallel_execution),
]
}
pub fn apply(&self, builder: SessionBuilder) -> Result<SessionBuilder> {
let mut builder = builder;
for option in self.required_options() {
builder = match option {
SessionBuildOption::MemoryPattern(enable) => {
builder.with_memory_pattern(enable).map_err(ort_error)?
}
SessionBuildOption::ParallelExecution(enable) => {
builder.with_parallel_execution(enable).map_err(ort_error)?
}
};
}
Ok(builder)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBuildOption {
MemoryPattern(bool),
ParallelExecution(bool),
}
#[derive(Debug)]
pub struct OrtSession {
session: Session,
model_path: String,
fallback_model_bytes: Option<std::sync::Arc<[u8]>>,
dimension_overrides: Vec<DimensionOverride>,
runtime_config: RuntimeConfig,
provider_resolution: ProviderResolution,
session_threads: (Option<usize>, Option<usize>),
onnx_opset: Option<u32>,
pub output_names: Vec<String>,
}
fn ort_error<E: std::fmt::Display>(error: E) -> RapidOcrError {
RapidOcrError::Decode(format!("ONNX Runtime error: {error}"))
}
impl OrtSession {
pub fn model_path(&self) -> &Path {
Path::new(&self.model_path)
}
#[cfg(test)]
pub(crate) fn open_unchecked(
model_path: &Path,
runtime_cfg: &RuntimeConfig,
dimension_overrides: &[DimensionOverride],
) -> Result<Self> {
let bytes: std::sync::Arc<[u8]> = std::fs::read(model_path)?.into();
Self::open_bytes(model_path, bytes, runtime_cfg, dimension_overrides)
}
pub fn open_verified(
model: &VerifiedModel,
runtime_cfg: &RuntimeConfig,
dimension_overrides: &[DimensionOverride],
) -> Result<Self> {
Self::open_bytes(
model.path(),
model.content_arc(),
runtime_cfg,
dimension_overrides,
)
}
fn open_bytes(
model_path: &Path,
model_bytes: std::sync::Arc<[u8]>,
runtime_cfg: &RuntimeConfig,
dimension_overrides: &[DimensionOverride],
) -> Result<Self> {
let opened = open_session(model_path, &model_bytes, runtime_cfg, dimension_overrides)?;
let output_names = opened
.session
.outputs()
.iter()
.map(|value| value.name().to_string())
.collect();
Ok(Self {
session: opened.session,
model_path: model_path.display().to_string(),
fallback_model_bytes: (matches!(
runtime_cfg.provider_preference,
crate::config::ProviderPreference::DirectMl { .. }
) && !runtime_cfg.fail_if_provider_unavailable)
.then_some(model_bytes),
dimension_overrides: dimension_overrides.to_vec(),
runtime_config: runtime_cfg.clone(),
provider_resolution: opened.provider_resolution,
session_threads: opened.session_threads,
onnx_opset: opened.onnx_opset,
output_names,
})
}
pub fn onnx_opset(&self) -> Option<u32> {
self.onnx_opset
}
pub fn session_threads(&self) -> (Option<usize>, Option<usize>) {
self.session_threads
}
pub fn metadata_custom(&self, key: &str) -> Result<Option<String>> {
match self.session.metadata().map_err(ort_error)?.custom(key) {
Some(raw) if !raw.trim().is_empty() => Ok(Some(raw)),
_ => Ok(None),
}
}
pub fn probe_io(&self) -> Result<ModelIoProbe> {
let inputs = self
.session
.inputs()
.iter()
.map(tensor_spec_from_outlet)
.collect::<Result<Vec<_>>>()?;
let outputs = self
.session
.outputs()
.iter()
.map(tensor_spec_from_outlet)
.collect::<Result<Vec<_>>>()?;
Ok(ModelIoProbe { inputs, outputs })
}
pub fn run_i64_2d(
&mut self,
input_name: &str,
input: ndarray::ArrayView4<'_, f32>,
) -> Result<Array2<i64>> {
let input_tensor = TensorRef::from_array_view(input).map_err(ort_error)?;
let first_error = match self.session.run(inputs![input_name => input_tensor]) {
Ok(outputs) => {
return Self::output_i64_2d(outputs, &self.model_path, &self.output_names);
}
Err(error) => error,
};
if self.provider_resolution.selected_ep == ResolvedExecutionProvider::DirectMl
&& !self.runtime_config.fail_if_provider_unavailable
{
self.fallback_to_cpu()?;
let input_tensor = TensorRef::from_array_view(input).map_err(ort_error)?;
let outputs = self
.session
.run(inputs![input_name => input_tensor])
.map_err(ort_error)?;
return Self::output_i64_2d(outputs, &self.model_path, &self.output_names);
}
Err(ort_error(first_error))
}
fn output_i64_2d(
outputs: ort::session::SessionOutputs<'_>,
model_path: &str,
output_names: &[String],
) -> Result<Array2<i64>> {
let output_name = output_names.first().ok_or_else(|| {
RapidOcrError::Decode(format!(
"ONNX session has no output names (model={model_path})"
))
})?;
let output = outputs.get(output_name.as_str()).ok_or_else(|| {
RapidOcrError::Decode(format!(
"ONNX session output `{output_name}` not found in run results (model={model_path})"
))
})?;
let arr = output.try_extract_array::<i64>().map_err(|error| {
RapidOcrError::Decode(format!(
"failed to extract output `{output_name}` as i64 tensor (model={model_path}): {error}"
))
})?;
arr.into_dimensionality::<Ix2>()
.map(|view| view.to_owned())
.map_err(|error| {
RapidOcrError::Decode(format!(
"unexpected output rank for model {model_path}: expected rank2: {error}"
))
})
}
pub fn provider_resolution(&self) -> ProviderResolution {
self.provider_resolution
}
pub fn run_arrayd_view_with<T, F>(
&mut self,
input: ndarray::ArrayView4<'_, f32>,
f: F,
) -> Result<T>
where
F: for<'a> FnOnce(ArrayViewD<'a, f32>) -> Result<T>,
{
let output_names = self.output_names.clone();
let model_path = self.model_path.clone();
self.run_with_fallback(input, |outputs| {
let output_name = output_names.first().ok_or_else(|| {
RapidOcrError::Decode(format!(
"ONNX session has no output names (model={})",
model_path
))
})?;
let output = outputs.get(output_name.as_str()).ok_or_else(|| {
RapidOcrError::Decode(format!(
"ONNX session output `{output_name}` not found in run results (model={})",
model_path
))
})?;
let arr = output.try_extract_array::<f32>().map_err(|error| {
RapidOcrError::Decode(format!(
"failed to extract output `{output_name}` as f32 tensor (model={}): {error}",
model_path
))
})?;
f(arr.view())
})
}
pub fn run_all_f32_view_with<T, F>(
&mut self,
input: ndarray::ArrayView4<'_, f32>,
f: F,
) -> Result<T>
where
F: for<'a> FnOnce(&'a [(&'a str, ArrayViewD<'a, f32>)]) -> Result<T>,
{
let output_names = self.output_names.clone();
let model_path = self.model_path.clone();
self.run_with_fallback(input, |outputs| {
let mut views: Vec<(&str, ArrayViewD<'_, f32>)> =
Vec::with_capacity(output_names.len());
for name in &output_names {
let output = outputs.get(name.as_str()).ok_or_else(|| {
RapidOcrError::Decode(format!(
"ONNX session output `{name}` not found in run results (model={})",
model_path
))
})?;
let arr = output.try_extract_array::<f32>().map_err(|error| {
RapidOcrError::Decode(format!(
"failed to extract output `{name}` as f32 tensor (model={}): {error}",
model_path
))
})?;
views.push((name.as_str(), arr));
}
f(&views)
})
}
fn run_with_fallback<T, F>(&mut self, input: ndarray::ArrayView4<'_, f32>, f: F) -> Result<T>
where
F: for<'a> FnOnce(ort::session::SessionOutputs<'a>) -> Result<T>,
{
let input_tensor = TensorRef::from_array_view(input).map_err(ort_error)?;
let first_error = match self.session.run(inputs![input_tensor]) {
Ok(outputs) => return f(outputs),
Err(error) => error,
};
if self.provider_resolution.selected_ep == ResolvedExecutionProvider::DirectMl
&& !self.runtime_config.fail_if_provider_unavailable
{
self.fallback_to_cpu()?;
let input_tensor = TensorRef::from_array_view(input).map_err(ort_error)?;
let outputs = self.session.run(inputs![input_tensor]).map_err(ort_error)?;
return f(outputs);
}
Err(ort_error(first_error))
}
fn fallback_to_cpu(&mut self) -> Result<()> {
let mut cpu_config = self.runtime_config.clone();
cpu_config.provider_preference = crate::config::ProviderPreference::Cpu;
cpu_config.fail_if_provider_unavailable = false;
let opened = open_session_once(
Path::new(&self.model_path),
self.fallback_model_bytes
.as_deref()
.expect("non-strict DirectML sessions retain their verified model bytes"),
&cpu_config,
&self.dimension_overrides,
)?;
self.session = opened.session;
self.provider_resolution = ProviderResolution {
requested: self.runtime_config.provider_preference,
selected_ep: ResolvedExecutionProvider::Cpu,
fallback_used: true,
};
Ok(())
}
pub fn run_array2_view_with<T, F>(
&mut self,
input: ndarray::ArrayView4<'_, f32>,
f: F,
) -> Result<T>
where
F: for<'a> FnOnce(ArrayView2<'a, f32>) -> Result<T>,
{
let model_path = self.model_path.clone();
self.run_arrayd_view_with(input, |arr| {
let arr = arr.into_dimensionality::<Ix2>().map_err(|error| {
RapidOcrError::Decode(format!(
"unexpected output rank for model {model_path}: expected rank2: {error}"
))
})?;
f(arr)
})
}
pub fn run_array3_view_with<T, F>(
&mut self,
input: ndarray::ArrayView4<'_, f32>,
f: F,
) -> Result<T>
where
F: for<'a> FnOnce(ArrayView3<'a, f32>) -> Result<T>,
{
let model_path = self.model_path.clone();
self.run_arrayd_view_with(input, |arr| {
let arr = arr.into_dimensionality::<Ix3>().map_err(|error| {
RapidOcrError::Decode(format!(
"unexpected output rank for model {model_path}: expected rank3: {error}"
))
})?;
f(arr)
})
}
pub fn run_array4_view_with<T, F>(
&mut self,
input: ndarray::ArrayView4<'_, f32>,
f: F,
) -> Result<T>
where
F: for<'a> FnOnce(ndarray::ArrayView4<'a, f32>) -> Result<T>,
{
let model_path = self.model_path.clone();
self.run_arrayd_view_with(input, |arr| {
let arr = arr.into_dimensionality::<Ix4>().map_err(|error| {
RapidOcrError::Decode(format!(
"unexpected output rank for model {model_path}: expected rank4: {error}"
))
})?;
f(arr)
})
}
}
struct OpenedSession {
session: Session,
provider_resolution: ProviderResolution,
session_threads: (Option<usize>, Option<usize>),
onnx_opset: Option<u32>,
}
fn open_session(
model_path: &Path,
model_bytes: &[u8],
runtime_cfg: &RuntimeConfig,
dimension_overrides: &[DimensionOverride],
) -> Result<OpenedSession> {
match open_session_once(model_path, model_bytes, runtime_cfg, dimension_overrides) {
Ok(opened) => Ok(opened),
Err(_accelerator_error)
if matches!(
runtime_cfg.provider_preference,
crate::config::ProviderPreference::DirectMl { .. }
) && !runtime_cfg.fail_if_provider_unavailable =>
{
let mut cpu_config = runtime_cfg.clone();
cpu_config.provider_preference = crate::config::ProviderPreference::Cpu;
cpu_config.fail_if_provider_unavailable = false;
let mut fallback =
open_session_once(model_path, model_bytes, &cpu_config, dimension_overrides)?;
fallback.provider_resolution = ProviderResolution {
requested: runtime_cfg.provider_preference,
selected_ep: ResolvedExecutionProvider::Cpu,
fallback_used: true,
};
Ok(fallback)
}
Err(error) => Err(error),
}
}
fn open_session_once(
model_path: &Path,
model_bytes: &[u8],
runtime_cfg: &RuntimeConfig,
dimension_overrides: &[DimensionOverride],
) -> Result<OpenedSession> {
let mut builder = Session::builder().map_err(ort_error)?;
let provider_chain = resolve_execution_providers(
&runtime_cfg.provider_preference,
runtime_cfg.enable_cpu_mem_arena,
runtime_cfg.fail_if_provider_unavailable,
)?;
builder =
SessionBuildPolicy::for_provider(provider_chain.resolution.selected_ep).apply(builder)?;
builder = builder
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(ort_error)?;
for dimension_override in dimension_overrides {
builder = builder
.with_dimension_override(&dimension_override.name, dimension_override.size)
.map_err(ort_error)?;
}
let (intra, inter) = runtime_cfg.effective_session_threads();
if let Some(intra) = intra {
builder = builder.with_intra_threads(intra).map_err(ort_error)?;
}
if let Some(inter) = inter {
builder = builder.with_inter_threads(inter).map_err(ort_error)?;
}
builder = builder
.with_execution_providers(provider_chain.providers)
.map_err(ort_error)?;
let session = builder.commit_from_memory(model_bytes).map_err(ort_error)?;
let onnx_opset = session.opset_for_domain(ort::editor::ONNX_DOMAIN);
if provider_chain.resolution.selected_ep == ResolvedExecutionProvider::DirectMl {
require_direct_ml_opset(onnx_opset, model_path)?;
}
Ok(OpenedSession {
session,
provider_resolution: provider_chain.resolution,
session_threads: (intra, inter),
onnx_opset,
})
}
fn tensor_spec_from_outlet(outlet: &ort::value::Outlet) -> Result<TensorSpec> {
let dtype = outlet.dtype();
if !dtype.is_tensor() {
return Err(RapidOcrError::Config(format!(
"model io `{}` must be a tensor, got `{dtype}`",
outlet.name()
)));
}
let shape = dtype.tensor_shape().ok_or_else(|| {
RapidOcrError::Config(format!("model io `{}` has no tensor shape", outlet.name()))
})?;
let element_type = dtype.tensor_type().ok_or_else(|| {
RapidOcrError::Config(format!(
"model io `{}` has no tensor element type",
outlet.name()
))
})?;
let dimension_symbols = match dtype {
ValueType::Tensor {
dimension_symbols, ..
} => dimension_symbols
.iter()
.map(|symbol| {
if symbol.is_empty() {
None
} else {
Some(symbol.clone())
}
})
.collect(),
_ => Vec::new(),
};
Ok(TensorSpec {
name: outlet.name().to_string(),
rank: shape.len(),
dims: shape.to_vec(),
dimension_symbols,
element_type,
})
}
#[cfg(test)]
mod tests {
use super::{
DIRECT_ML_MAX_OPSET, OrtSession, ResolvedExecutionProvider, SessionBuildOption,
SessionBuildPolicy, require_direct_ml_opset,
};
use crate::config::{ProviderPreference, RuntimeConfig};
#[test]
fn the_cpu_and_cuda_policies_ask_for_no_builder_option_at_all() {
for ep in [
ResolvedExecutionProvider::Cpu,
ResolvedExecutionProvider::Cuda,
] {
let policy = SessionBuildPolicy::for_provider(ep);
assert_eq!(policy, SessionBuildPolicy::ORT_DEFAULTS, "{ep:?}");
assert!(
policy.required_options().is_empty(),
"{ep:?} must not touch any builder option"
);
}
}
#[test]
fn direct_ml_disables_memory_pattern_and_parallel_execution() {
let policy = SessionBuildPolicy::for_provider(ResolvedExecutionProvider::DirectMl);
assert!(policy.must_be_explicit);
assert!(!policy.memory_pattern);
assert!(!policy.parallel_execution);
assert_eq!(
policy.required_options(),
vec![
SessionBuildOption::MemoryPattern(false),
SessionBuildOption::ParallelExecution(false),
]
);
assert!(
SessionBuildPolicy::ORT_DEFAULTS
.required_options()
.is_empty()
);
}
#[test]
fn direct_ml_accepts_opset_20_and_rejects_21() {
let path = std::path::Path::new("model.onnx");
assert!(require_direct_ml_opset(Some(DIRECT_ML_MAX_OPSET), path).is_ok());
assert!(require_direct_ml_opset(Some(DIRECT_ML_MAX_OPSET - 1), path).is_ok());
let error = require_direct_ml_opset(Some(DIRECT_ML_MAX_OPSET + 1), path)
.expect_err("opset above the DirectML ceiling must be refused");
let message = error.to_string();
assert!(
message.contains("opset 21") && message.contains("DirectML"),
"the error must name both the offending opset and the provider: {message}"
);
}
#[test]
fn an_unreadable_opset_is_not_a_violation() {
assert!(require_direct_ml_opset(None, std::path::Path::new("model.onnx")).is_ok());
}
#[test]
fn the_real_model_reports_its_opset() {
let Some(path) = crate::test_support::table_structure_model_path() else {
return;
};
let session = OrtSession::open_unchecked(&path, &RuntimeConfig::default(), &[])
.expect("the real model must load");
let opset = session.onnx_opset();
println!("slanet-plus opset = {opset:?}");
let opset = opset.expect("the bundled runtime must expose the Model Editor API");
assert!(
opset <= DIRECT_ML_MAX_OPSET,
"the table model must stay inside the DirectML opset ceiling, got {opset}"
);
}
#[test]
fn every_run_entrance_takes_an_exclusive_borrow() {
let source = include_str!("session.rs");
let gate_prefix = ["#[cfg", "("].concat();
let lines = source.lines().collect::<Vec<_>>();
let tests_gate = lines
.iter()
.enumerate()
.find_map(|(index, line)| {
let trimmed = line.trim();
if !(trimmed.starts_with(&gate_prefix) && trimmed.contains("test")) {
return None;
}
let declaration = lines[index + 1..]
.iter()
.find(|next| !next.trim().is_empty())?
.trim();
["mod ", "pub mod ", "pub(crate) mod ", "pub(super) mod "]
.iter()
.any(|head| declaration.starts_with(head))
.then_some(index)
})
.expect("this file must keep a cfg-gated test module declaration");
let production = lines[..tests_gate].join("\n");
assert!(
!production.is_empty() && production.len() < source.len(),
"the scan must be limited to production code, otherwise it reads its own literals"
);
let run_call = [".run", "(inputs"].concat();
assert_eq!(
production.matches(&run_call).count(),
4,
"`{run_call}` must have one DirectML attempt and one CPU retry in each inference path"
);
let raw_signature = ["fn ", "run_with_fallback"].concat();
assert!(
production.contains(&raw_signature),
"all f32 run paths must share `{raw_signature}`"
);
let prefix = ["pub fn ", "run_"].concat();
let mut entrances = 0;
for (index, _) in production.match_indices(&prefix) {
let line_prefix = production[..index].rsplit('\n').next().unwrap_or("");
if !line_prefix.trim().is_empty() {
continue;
}
entrances += 1;
let signature = &production[index..(index + 200).min(production.len())];
let name = signature.lines().next().unwrap_or(signature).trim();
assert!(
signature.contains("&mut self"),
"`{name}` must take `&mut self`, otherwise parallel `Run` becomes expressible"
);
}
assert!(
entrances >= 6,
"the scan must actually find the run entrances, found {entrances}"
);
}
#[test]
fn serialized_sharing_of_one_session_produces_the_same_outputs() {
let Some(path) = crate::test_support::table_structure_model_path() else {
return;
};
let config = crate::table::structure::TableStructureConfig::default();
let credential = crate::model_verify::VerifiedModel::verify(&path, None)
.expect("the real model must be readable");
let session = crate::table::structure::TableStructureSession::open(
&credential,
&RuntimeConfig::default(),
&config,
)
.expect("the real model must load");
let session = std::sync::Arc::new(std::sync::Mutex::new(session));
let side = config.input_side_len;
let input = ndarray::Array4::<f32>::from_elem((1, 3, side, side), 0.5);
let run = |session: &std::sync::Arc<
std::sync::Mutex<crate::table::structure::TableStructureSession>,
>,
input: &ndarray::Array4<f32>| {
let mut guard = session.lock().expect("the mutex is not poisoned");
guard
.run_with(input.view(), |outputs| {
Ok(outputs
.boxes
.iter()
.map(|v| v.to_bits())
.collect::<Vec<_>>())
})
.expect("inference must succeed")
};
let sequential = run(&session, &input);
let shared = std::thread::scope(|scope| {
let handles: Vec<_> = (0..2)
.map(|_| {
let session = std::sync::Arc::clone(&session);
let input = input.clone();
scope.spawn(move || run(&session, &input))
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().expect("no thread may panic"))
.collect::<Vec<_>>()
});
assert_eq!(shared.len(), 2);
for observed in &shared {
assert_eq!(
observed, &sequential,
"a serialized shared run must produce the same bits as a sequential one"
);
}
}
#[test]
fn the_default_provider_follows_the_directml_feature() {
assert_eq!(
RuntimeConfig::default().provider_preference,
ProviderPreference::default()
);
}
}