Skip to main content

onnx_runtime_eager/
lib.rs

1//! # `onnx-runtime-eager`
2//!
3//! Eager (single-op) execution for the ORT 2.0 runtime (`docs/EAGER.md`).
4//! Dispatch individual ONNX ops to execution-provider kernels **without building
5//! a graph** — a PyTorch-style experience with ONNX op semantics.
6//!
7//! This crate is the pure-Rust core of the design. It reuses the existing
8//! runtime abstractions rather than inventing parallel ones:
9//!
10//! * [`ExecutionProvider`](onnx_runtime_ep_api::ExecutionProvider) /
11//!   [`Kernel`](onnx_runtime_ep_api::Kernel) /
12//!   [`OpRegistry`](onnx_runtime_ep_api::OpRegistry) from `onnx-runtime-ep-api`,
13//! * the populated CPU registry from `onnx-runtime-ep-cpu`,
14//! * per-op shape/dtype inference from `onnx-runtime-shape-inference`,
15//! * the IR vocabulary ([`Node`](onnx_runtime_ir::Node),
16//!   [`DataType`](onnx_runtime_ir::DataType),
17//!   [`Attribute`](onnx_runtime_ir::Attribute), …) from `onnx-runtime-ir`.
18//!
19//! ## Phase-1 scope (`docs/EAGER.md`)
20//!
21//! Implemented: [`EagerContext`], [`EagerContext::dispatch`] (single-op CPU
22//! dispatch), the [`DomainRegistry`](domain::DomainRegistry), opset resolution
23//! ([`opset`]), and the compiled-[`KernelCache`](cache::KernelCache).
24//!
25//! DEFERRED (each is flagged at its hook point):
26//! * PyO3 / Python bindings (§11), including the `Tensor` single→tensor /
27//!   multi→tuple sugar and `ops.*` typed wrappers (§4.1).
28//! * Subgraph ops `If`/`Loop`/`Scan` (§7).
29//! * The opset context-manager / `nxrt.device()` context (§5.3, §2.2).
30//! * GPU/CUDA EP dispatch and implicit cross-device transfer.
31//! * Kernel-provided shape-inference fallback (§9.2).
32//! * DLPack / numpy interop (§3).
33
34mod cache;
35mod dispatch;
36mod domain;
37mod error;
38mod opset;
39mod tensor;
40
41use std::sync::Arc;
42use std::sync::{Mutex, OnceLock, RwLock};
43
44use onnx_runtime_ep_api::{EpConfig, ExecutionProvider};
45use onnx_runtime_ep_cpu::CpuExecutionProvider;
46use onnx_runtime_ir::DeviceId;
47use onnx_runtime_shape_inference::InferenceRegistry;
48
49pub use cache::{CacheStats, KernelCache, KernelCacheKey};
50pub use domain::{DomainInfo, DomainRegistry};
51pub use error::{EagerError, Result};
52pub use opset::{LATEST_ONNX_OPSET, resolve_opset};
53pub use tensor::Tensor;
54
55/// Default compiled-kernel cache capacity (`docs/EAGER.md` §13: per-process LRU,
56/// 4096).
57const DEFAULT_CACHE_CAPACITY: usize = 4096;
58
59/// The process-wide eager execution context (`docs/EAGER.md` §10.1).
60///
61/// Manages the available EPs, the domain registry, per-op shape inference, the
62/// compiled-kernel cache, and the default device. It is thread-safe via internal
63/// locking so it can back the [`global_context`] singleton.
64pub struct EagerContext {
65    /// Available EPs, auto-detected at initialization. CPU only in Phase-1;
66    /// GPU EPs are added here as a *registration*, not a rewrite.
67    pub(crate) eps: Vec<Arc<dyn ExecutionProvider>>,
68    /// Compiled-kernel cache (keyed by op + shapes + dtypes + device).
69    pub(crate) cache: Mutex<KernelCache>,
70    /// Domain registry (standard + custom).
71    pub(crate) domains: RwLock<DomainRegistry>,
72    /// Per-op output shape/dtype inference (§9).
73    pub(crate) inference: InferenceRegistry,
74    /// Default device for ops with no inputs (`docs/EAGER.md` §4.3 case 3).
75    pub(crate) default_device: DeviceId,
76}
77
78/// Auto-detect the available execution providers (`docs/EAGER.md` §10.1
79/// `detect_available_eps`).
80///
81/// Phase-1: the always-available CPU EP only. Adding a GPU EP later is a matter
82/// of pushing another initialized provider here — the dispatch path selects an
83/// EP generically by device, with no hardcoded backend/vendor name.
84fn detect_available_eps() -> Result<Vec<Arc<dyn ExecutionProvider>>> {
85    // DEFERRED (EAGER.md §4.3 / §2.2): GPU/CUDA EP discovery + auto-transfer.
86    let mut cpu = CpuExecutionProvider::new();
87    cpu.initialize(&EpConfig::default())?;
88    Ok(vec![Arc::new(cpu) as Arc<dyn ExecutionProvider>])
89}
90
91impl EagerContext {
92    /// Initialize a context with auto-detected devices (`docs/EAGER.md` §10.1).
93    pub fn new() -> Result<Self> {
94        let eps = detect_available_eps()?;
95        let default_device = eps
96            .first()
97            .map(|ep| ep.device_id())
98            .unwrap_or_else(DeviceId::cpu);
99        Ok(Self {
100            eps,
101            cache: Mutex::new(KernelCache::new(DEFAULT_CACHE_CAPACITY)),
102            domains: RwLock::new(DomainRegistry::new()),
103            inference: InferenceRegistry::default_registry(),
104            default_device,
105        })
106    }
107
108    /// The default device for input-less ops (`docs/EAGER.md` §4.3).
109    pub fn default_device(&self) -> DeviceId {
110        self.default_device
111    }
112
113    /// Register (or update) a custom domain's default opset (`docs/EAGER.md`
114    /// §6.1 `register_domain`).
115    pub fn register_domain(&self, domain: &str, default_opset: u64) {
116        self.domains
117            .write()
118            .expect("domain registry lock poisoned")
119            .register(domain, default_opset);
120    }
121
122    /// All registered domains and their default opsets (`docs/EAGER.md` §6.1
123    /// `domains()`).
124    pub fn domains(&self) -> Vec<(String, u64)> {
125        self.domains
126            .read()
127            .expect("domain registry lock poisoned")
128            .domains()
129    }
130
131    /// Current kernel-cache statistics (entries / hits / misses).
132    pub fn cache_stats(&self) -> CacheStats {
133        self.cache
134            .lock()
135            .expect("kernel cache lock poisoned")
136            .stats()
137    }
138
139    /// Resolve the single target device from `inputs`; mixed devices are an
140    /// error (`docs/EAGER.md` §1.6 / §4.3, Design Decision).
141    pub(crate) fn resolve_device(&self, inputs: &[&Tensor]) -> Result<DeviceId> {
142        let devices: Vec<DeviceId> = inputs.iter().map(|t| t.device()).collect();
143        resolve_device_from_ids(&devices, self.default_device)
144    }
145
146    /// Get the EP (as an owned `Arc` clone) that owns `device`.
147    pub(crate) fn ep_for_device(&self, device: DeviceId) -> Result<Arc<dyn ExecutionProvider>> {
148        self.eps
149            .iter()
150            .find(|ep| ep.device_id() == device)
151            .cloned()
152            .ok_or(EagerError::NoEpForDevice(device))
153    }
154}
155
156/// Resolve a single device from a list of input devices (`docs/EAGER.md` §4.3):
157/// no inputs → `default`; all on one device → that device; otherwise a
158/// [`EagerError::MixedDeviceInputs`] error.
159///
160/// Split out from [`EagerContext::resolve_device`] so the mixed-device policy is
161/// unit-testable with fabricated [`DeviceId`]s even on a CPU-only build.
162pub(crate) fn resolve_device_from_ids(devices: &[DeviceId], default: DeviceId) -> Result<DeviceId> {
163    let mut unique: Vec<DeviceId> = Vec::new();
164    for &d in devices {
165        if !unique.contains(&d) {
166            unique.push(d);
167        }
168    }
169    match unique.len() {
170        0 => Ok(default),
171        1 => Ok(unique[0]),
172        _ => Err(EagerError::MixedDeviceInputs {
173            devices: unique,
174            hint: "use .to(device) to move all inputs to the same device".to_string(),
175        }),
176    }
177}
178
179static GLOBAL_CONTEXT: OnceLock<EagerContext> = OnceLock::new();
180
181/// Get or lazily initialize the process-global eager context (`docs/EAGER.md`
182/// §10.2).
183pub fn global_context() -> &'static EagerContext {
184    GLOBAL_CONTEXT.get_or_init(|| EagerContext::new().expect("failed to initialize eager context"))
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn _assert_send_sync<T: Send + Sync>() {}
192
193    #[test]
194    fn context_is_send_sync() {
195        // Required for the `OnceLock<EagerContext>` global singleton.
196        _assert_send_sync::<EagerContext>();
197    }
198
199    #[test]
200    fn new_context_detects_cpu() {
201        let ctx = EagerContext::new().unwrap();
202        assert_eq!(ctx.default_device(), DeviceId::cpu());
203        assert!(ctx.domains().iter().any(|(d, _)| d.is_empty()));
204    }
205
206    #[test]
207    fn resolve_device_same_device_ok() {
208        let ctx = EagerContext::new().unwrap();
209        let a = Tensor::from_raw_in(
210            ctx.ep_for_device(DeviceId::cpu()).unwrap(),
211            onnx_runtime_ir::DataType::Float32,
212            vec![2],
213            &1.0f32.to_le_bytes().repeat(2),
214        )
215        .unwrap();
216        let b = a.clone();
217        assert_eq!(ctx.resolve_device(&[&a, &b]).unwrap(), DeviceId::cpu());
218    }
219
220    #[test]
221    fn resolve_device_no_inputs_uses_default() {
222        let ctx = EagerContext::new().unwrap();
223        assert_eq!(ctx.resolve_device(&[]).unwrap(), ctx.default_device());
224    }
225
226    #[test]
227    fn resolve_device_mixed_is_error() {
228        // Fabricated CPU + CUDA device ids exercise the mixed-device guard even
229        // though only a CPU EP exists (a real cross-device tensor is infeasible
230        // CPU-only), per docs/EAGER.md §1.6.
231        use onnx_runtime_ir::DeviceType;
232        let cpu = DeviceId::cpu();
233        let cuda = DeviceId::new(DeviceType::Cuda, 0);
234        assert!(matches!(
235            resolve_device_from_ids(&[cpu, cuda], cpu),
236            Err(EagerError::MixedDeviceInputs { .. })
237        ));
238        // Same-device and empty cases resolve fine.
239        assert_eq!(resolve_device_from_ids(&[cpu, cpu], cpu).unwrap(), cpu);
240        assert_eq!(resolve_device_from_ids(&[], cpu).unwrap(), cpu);
241    }
242}