| name | ort |
| description | ONNX Runtime in Rust via the `ort` crate (2.x): loading sessions, configuring CPU/CoreML/CUDA execution providers, tensor I/O with ndarray, async-safe spawn_blocking wrapping, global thread-pool init, and debugging provider/opset issues |
| user-invocable | false |
| disable-model-invocation | true |
| version | 1.0.0 |
| category | toolchain |
| author | Claude MPM Team |
| license | MIT |
| progressive_disclosure | {"entry_point":{"summary":"Load ONNX models in Rust with the ort 2.x crate: commit the global thread pool once, build a Session with providers in priority order (CUDA > CoreML > CPU), run inference inside spawn_blocking, and mark integration tests #[ignore]","when_to_use":"When adding or using ONNX models in Rust — embedders, VAD/STT/TTS, classifiers; when targeting CoreML on Apple Silicon or CUDA on Linux/Windows; when debugging silent execution-provider fallback or opset compatibility","quick_start":"1. Add ort dep + features 2. Commit global thread pool once at startup 3. Build Session with providers in priority order 4. Wrap run() in spawn_blocking 5. Mark ONNX tests #[ignore]"},"token_estimate":{"entry":160,"full":4200}} |
| context_limit | 800 |
| tags | ["rust","ort","onnx","ml","inference","embeddings","coreml","cuda","ndarray"] |
| requires_tools | [] |
ONNX Runtime in Rust (ort 2.x)
Overview
ort is the Rust binding to ONNX Runtime. Use it when you need to run
ONNX-format models locally — text embedders, speech models, classifiers — with
hardware-accelerated execution providers (CoreML on Apple Silicon, CUDA on
Linux/Windows). The ort 2.x API changed significantly from 1.x; all patterns
here target ort = "=2.0.0-rc.12" (the version pinned by fastembed-rs).
Reference implementation: crates/trusty-common/src/embedder/fast_embedder.rs
Quick Start
1. Cargo.toml setup
Library crate (default-features = false avoids linking the bundled ORT runtime
unnecessarily; consumers opt into ONNX via a feature):
[dependencies.ort]
version = "=2.0.0-rc.12"
default-features = false
features = ["std", "api-24"]
optional = true
[target.'cfg(target_os = "macos")'.dependencies.ort]
version = "=2.0.0-rc.12"
default-features = false
features = ["coreml", "std", "api-24"]
optional = true
ndarray = { version = "0.16", optional = true }
Binary / daemon crate (load-dynamic avoids statically linking libonnxruntime
into the binary, reducing binary size when the shared lib is already present):
[dependencies.ort]
version = "=2.0.0-rc.12"
default-features = false
features = ["load-dynamic", "std", "api-24"]
ndarray tensor interop (add alongside ort):
ndarray = { version = "0.16", features = ["std"] }
Core Concepts
2. Global ORT thread pool — commit ONCE before any Session
ORT's per-session thread pool cannot be overridden from outside the session
builder. The only lever that reaches an opaque session (e.g., one built
internally by fastembed-rs) is the global environment thread pool. Commit it
exactly once, before any Session is created.
Why this matters: The deferred-embed deadlock (trusty-tools issue #1668 /
originally #1542) was traced to fastembed's internal session hardcoding
with_intra_threads(available_parallelism()). On a multi-core host, 8+ ORT
threads spawn; two busy-wait at ~70% CPU while the rest block forever inside
condition_variable::wait, yielding zero embeddings and an empty HNSW index.
Committing a global pool with intra_threads = 1 and spin_control = false
causes ORT to call DisablePerSessionThreads, overriding fastembed's value.
use std::sync::OnceLock;
static ORT_RUNTIME: OnceLock<OrtThreadingOptions> = OnceLock::new();
fn init_ort_runtime() {
ORT_RUNTIME.get_or_init(|| {
let intra = std::env::var("TRUSTY_ORT_INTRA_THREADS")
.ok()
.and_then(|v| v.parse::<i16>().ok())
.unwrap_or(1);
let inter = std::env::var("TRUSTY_ORT_INTER_THREADS")
.ok()
.and_then(|v| v.parse::<i16>().ok())
.unwrap_or(1);
let spinning = std::env::var("TRUSTY_ORT_ALLOW_SPINNING")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let pool = ort::environment::GlobalThreadPoolOptions::default()
.with_intra_threads(intra)
.and_then(|p| p.with_inter_threads(inter))
.and_then(|p| p.with_spin_control(spinning));
match pool {
Ok(pool) => {
let committed = ort::init().with_global_thread_pool(pool).commit();
if committed {
tracing::info!(
intra_threads = intra,
inter_threads = inter,
allow_spinning = spinning,
"ORT global thread pool committed (DisablePerSessionThreads active)"
);
} else {
tracing::warn!(
"ORT environment already committed before init_ort_runtime() — \
deadlock fix did NOT take effect; ensure no Session is created \
before calling init_ort_runtime()"
);
}
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to build ORT global thread pool options; falling back to defaults"
);
}
}
OrtThreadingOptions { intra_threads: intra, inter_threads: inter, allow_spinning: spinning }
});
}
3. Build a Session with providers in priority order
Providers are tried in registration order; the first available one wins. Always
register more-capable (faster) providers first with CPU as the unconditional
fallback.
use ort::{GraphOptimizationLevel, Session};
fn build_session(model_bytes: &[u8]) -> anyhow::Result<Session> {
let cpu = ort::execution_providers::CPU::default()
.with_arena_allocator(false)
.build();
let mut providers: Vec<ort::execution_providers::ExecutionProviderDispatch> = Vec::new();
#[cfg(feature = "embedder-cuda")]
{
use ort::ep::ArenaExtendStrategy;
providers.push(
ort::ep::CUDA::default()
.with_arena_extend_strategy(ArenaExtendStrategy::SameAsRequested)
.with_memory_limit(12 * 1024 * 1024 * 1024)
.build(),
);
}
#[cfg(all(target_os = "macos", feature = "coreml"))]
{
use ort::ep::CoreML;
providers.push(
CoreML::default()
.with_static_input_shapes(true)
.with_specialization_strategy(
ort::ep::coreml::CoreMLSpecializationStrategy::FastPrediction,
)
.build(),
);
}
providers.push(cpu);
let session = Session::builder()?
.with_optimization_level(GraphOptimizationLevel::All)?
.with_execution_providers(providers)?
.commit_from_memory_directly(model_bytes)?;
Ok(session)
}
Tip: commit_from_file, commit_from_memory, and commit_from_memory_directly
are the terminal builder calls. There is no standalone .build().
4. Tensor I/O with ndarray
use ndarray::{Array2, ArrayView2};
use ort::{inputs, tensor::{Tensor, TensorRef}};
fn run_inference(
session: &mut ort::Session,
input: ArrayView2<'_, f32>,
) -> anyhow::Result<Array2<f32>> {
let tensor = TensorRef::from_array_view(input)?;
let outputs = session.run(inputs!["input_ids" => &tensor])?;
let embedding: Array2<f32> = outputs[0]
.try_extract_array::<f32>()?
.into_dimensionality::<ndarray::Ix2>()?
.to_owned();
Ok(embedding)
}
fn build_dynamic_session(model_bytes: &[u8]) -> anyhow::Result<ort::Session> {
Ok(Session::builder()?
.with_memory_pattern(false)?
.with_optimization_level(GraphOptimizationLevel::All)?
.commit_from_memory_directly(model_bytes)?)
}
5. Async wrapping — never run inference on a tokio thread
Session is Send + Sync but run() takes &mut self. Wrap behind a
parking_lot::Mutex and call from spawn_blocking.
use parking_lot::Mutex;
use std::sync::Arc;
use tokio::task;
pub struct OrtInferencer {
session: Arc<Mutex<ort::Session>>,
}
impl OrtInferencer {
pub async fn new(model_bytes: Vec<u8>) -> anyhow::Result<Self> {
let session = task::spawn_blocking(move || -> anyhow::Result<ort::Session> {
init_ort_runtime();
build_session(&model_bytes)
})
.await
.context("spawn_blocking error during Session init")??;
Ok(Self {
session: Arc::new(Mutex::new(session)),
})
}
pub async fn embed(&self, input: Vec<f32>, shape: [usize; 2]) -> anyhow::Result<Vec<f32>> {
let session = Arc::clone(&self.session);
task::spawn_blocking(move || -> anyhow::Result<Vec<f32>> {
use ndarray::Array2;
let arr = Array2::from_shape_vec(shape, input)?;
let mut guard = session.lock();
let result = run_inference(&mut guard, arr.view())?;
Ok(result.into_raw_vec())
})
.await
.context("spawn_blocking error during inference")?
}
}
Anti-pattern: Mutex<Option<Session>> with lazy init — concurrent callers
all call spawn_blocking to race at init, exhaust the pool, and deadlock.
Initialise Arc<Session> eagerly at startup instead.
Testing
Mark ONNX integration tests #[ignore] so CI stays fast while still allowing
local validation with a real model:
#[tokio::test]
#[ignore = "requires ONNX model download (~22 MB); run with --include-ignored"]
async fn embedder_returns_correct_dim() {
let model_bytes = std::fs::read("tests/fixtures/model.onnx").unwrap();
let inferencer = OrtInferencer::new(model_bytes).await.unwrap();
let output = inferencer.embed(vec![0.0_f32; 128], [1, 128]).await.unwrap();
assert_eq!(output.len(), 384, "embedding dim should be 384");
}
#[tokio::test]
async fn mock_embedder_returns_correct_dim() {
let mock = MockEmbedder::new(384);
let result = mock.embed_batch(&["hello world".to_string()]).await.unwrap();
assert_eq!(result[0].len(), 384);
}
Run commands:
cargo test -p my-crate --features embedder
cargo test -p my-crate --features embedder -- --include-ignored
cargo test -p my-crate --features embedder -- embedder_returns_correct_dim --include-ignored --nocapture
Gotchas
Silent execution-provider fallback
ORT silently falls back to CPU when a registered EP is unavailable. Always log
the resolved EP or call ep.is_available() before building the Session.
if expected_provider != ExecutionProvider::Cpu {
tracing::error!(
"SILENT FALLBACK: {} EP failed — running on CPU. \
Inference will be slower than expected.",
expected_provider
);
}
CoreML silent FP16 cast
CoreML may silently cast FP32 weights to FP16, reducing precision. Export your
model in MLProgram format (mlpackage) rather than NeuralNetwork (mlmodel) to
avoid the cast.
CPU arena pre-allocation
ORT's default CPU BFCArena reserves 19–53 GB of virtual address space on first
inference (issue #89). Always pass .with_arena_allocator(false) on the CPU
provider.
ort::init() must precede all Sessions
Call init_ort_runtime() (which calls ort::init().commit()) before any
Session::builder()...commit_from_*() call. Once any session exists, the
global environment is locked and the thread-pool commit returns false.
run() needs &mut self — must use Mutex or pool
ORT's Session::run() takes &mut self. Wrap in Mutex<Session> for
concurrent access, or use a pool of sessions. Never expose &Session directly
from an Arc.
CoreML cold-start compile to .mlmodelc
On first run, CoreML compiles the model to a .mlmodelc bundle. Set a
persistent model_cache_dir on the CoreML EP so recompilation is skipped on
restarts. Without a cache dir, every process restart pays the compile cost.
Export opset
ONNX opset ≤ 16 is the safest target for broad ORT compatibility. ORT may
refuse or silently degrade models exported at opset 17+ with older runtime
versions.
Windows DLL conflicts with load-dynamic
When using the load-dynamic feature on Windows, ensure only one copy of
onnxruntime.dll is on PATH. Conflicting copies from different packages
cause runtime crashes at session creation.
Forgetting the "std" feature
Without features = ["std"], commit_from_file and related APIs are not
available. default-features = false is correct for library crates but always
add "std" back explicitly.
Decision Trees
Which session commit call?
| Situation | API |
|---|
| Model bytes already in memory, bytes outlive session | commit_from_memory_directly(&bytes) |
Model bytes in a Vec<u8>, want ORT to own a copy | commit_from_memory(bytes.as_slice()) |
| Model is a file on disk | commit_from_file("/path/to/model.onnx") |
Which execution provider?
| Hardware | Recommended EP | Notes |
|---|
| Apple Silicon (M1–M4) | CoreML | Add features = ["coreml"]; cold-compile on first run |
| NVIDIA GPU (Linux) | CUDA | Add features = ["cuda"]; cap memory with with_memory_limit |
| CPU-only / CI | CPU | Always last; disable arena with with_arena_allocator(false) |
Anti-Patterns
- Calling
ort::init() after creating any Session — the commit returns false and the thread pool is not set.
- Using
unwrap() inside spawn_blocking closures — panics in a blocking thread are not caught by the async runtime; use ? and propagate via anyhow::Result.
- Deferring Session init behind
Mutex<Option<Session>> — concurrent callers exhaust the blocking thread pool (the #1668 deadlock).
- Registering CUDA or CoreML but not logging the fallback when they are unavailable — silent performance regressions.
- Dynamic batch inference without
.with_memory_pattern(false) — ORT pre-allocates for the first batch shape and panics on a different shape.
Resources