onnx_runtime_session/lib.rs
1//! # `onnx-runtime-session`
2//!
3//! The user-facing session and inference API for the ORT 2.0 runtime
4//! (see `docs/ORT2.md` §20). Design goal: **zero-config by default** — the user
5//! never has to know what an execution provider is; the runtime auto-detects
6//! hardware and picks a strategy.
7//!
8//! **Phase 1 skeleton:** the intent-based [`SessionBuilder`] and
9//! [`InferenceSession`] surfaces are defined; `build`/`run` bodies are
10//! `todo!()` pending the sequential executor (Phase 1 task `ort2-session`).
11//!
12//! ```ignore
13//! let mut session = onnx_runtime_session::load("model.onnx")?;
14//! let outputs = session.run(&[("input_ids", &tensor)])?;
15//! ```
16
17use 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;
28pub use tensor::Tensor;
29
30mod epcontext;
31mod executor;
32mod tensor;
33
34mod error {
35 /// Errors produced by the session layer.
36 #[derive(Debug, thiserror::Error)]
37 pub enum SessionError {
38 #[error("session not initialized")]
39 NotInitialized,
40
41 #[error("input not found: {name}")]
42 InputNotFound { name: String },
43
44 #[error("unknown session option: {key}")]
45 UnknownOption { key: String },
46
47 #[error("invalid value {value:?} for session option {key:?}: expected one of {expected}")]
48 InvalidOption {
49 key: String,
50 value: String,
51 expected: String,
52 },
53
54 #[error("no model source: set a path or bytes on the builder")]
55 NoModelSource,
56
57 #[error("op type not supported by any available EP: {op_type}")]
58 UnsupportedOp { op_type: String },
59
60 #[error("value has a non-static (symbolic) shape and no binding to resolve it: {value}")]
61 DynamicShape { value: String },
62
63 #[error(
64 "symbol {symbol} bound to conflicting sizes {first} and {second} across bound inputs"
65 )]
66 SymbolConflict {
67 symbol: String,
68 first: usize,
69 second: usize,
70 },
71
72 #[error("input {name}: rank mismatch (graph declares rank {expected}, got {got})")]
73 RankMismatch {
74 name: String,
75 expected: usize,
76 got: usize,
77 },
78
79 #[error("no inferred shape for value {value} produced by op {op}")]
80 UnresolvedShape { value: String, op: String },
81
82 #[error("shape element count overflows usize for value {value} (dims {dims:?})")]
83 ShapeOverflow { value: String, dims: Vec<usize> },
84
85 #[error(
86 "op {op} produced {got} data-dependent output shape(s) but has {expected} output(s)"
87 )]
88 OutputShapeCountMismatch {
89 op: String,
90 expected: usize,
91 got: usize,
92 },
93
94 #[error("input {name}: dtype mismatch (expected {expected}, got {got})")]
95 DtypeMismatch {
96 name: String,
97 expected: String,
98 got: String,
99 },
100
101 #[error("input {name}: shape mismatch (expected {expected:?}, got {got:?})")]
102 ShapeMismatch {
103 name: String,
104 expected: Vec<usize>,
105 got: Vec<usize>,
106 },
107
108 #[error("internal executor error: {0}")]
109 Internal(String),
110
111 #[error(
112 "EPContext reference node (main_context=0) has no matching primary \
113 (source={source_key:?}, partition_name={partition_name:?})"
114 )]
115 DanglingEpContext {
116 source_key: Option<String>,
117 partition_name: Option<String>,
118 },
119
120 #[error(transparent)]
121 Load(#[from] onnx_runtime_loader::LoaderError),
122
123 #[error(transparent)]
124 Ep(#[from] onnx_runtime_ep_api::EpError),
125
126 #[error(transparent)]
127 Ir(#[from] onnx_runtime_ir::IrError),
128
129 #[error(transparent)]
130 Graph(#[from] onnx_runtime_ir::GraphError),
131
132 #[error(transparent)]
133 Optimize(#[from] onnx_runtime_optimizer::OptimizerError),
134
135 #[error(transparent)]
136 ShapeInfer(#[from] onnx_runtime_shape_inference::ShapeInferError),
137 }
138
139 /// Session `Result` alias.
140 pub type Result<T> = std::result::Result<T, SessionError>;
141}
142
143use error::Result;
144
145/// Metadata describing a model input or output (§20.2).
146#[derive(Clone, Debug)]
147pub struct IoMeta {
148 pub name: String,
149 pub dtype: DataType,
150 pub shape: Shape,
151}
152
153/// Intent-based device preference (§20.4). The runtime maps this to concrete
154/// EPs during `build`.
155#[derive(Clone, Debug, Default, PartialEq, Eq)]
156pub enum DevicePreference {
157 /// Pick the best available device automatically.
158 #[default]
159 Auto,
160 /// Prefer CPU execution.
161 Cpu,
162 /// Prefer a GPU / accelerator, optionally by ordinal.
163 Gpu { index: Option<u32> },
164 /// Pin to a specific device class + ordinal.
165 Explicit { device_type: DeviceType, index: u32 },
166}
167
168/// A shape to pre-compile kernels for at session init (§11.3).
169#[derive(Clone, Debug)]
170pub struct WarmupShape {
171 pub input_name: String,
172 pub shape: Vec<usize>,
173}
174
175/// Graph-optimization level for the session's `optimize` pipeline stage
176/// (`docs/ORT2.md` §18). Selected via the generic `"optimization"` session
177/// option (see [`SessionBuilder::option`]).
178///
179/// The default is [`OptimizationLevel::None`]: with optimization off the graph
180/// reaches the executor exactly as the loader produced it, so default runtime
181/// behavior is byte-identical to a build with no optimizer wired in at all.
182///
183/// This is a generic, model-agnostic knob — no level ever special-cases a model
184/// name or op. Higher levels simply enable more of the device-independent pass
185/// pipeline from [`onnx_runtime_optimizer`].
186#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
187pub enum OptimizationLevel {
188 /// No passes — the `optimize` stage is a no-op (default).
189 #[default]
190 None,
191 /// Structure-preserving passes only: constant folding then dead-node
192 /// elimination. No operator fusion, so the op set the executor sees is a
193 /// subset of the loaded graph's.
194 Basic,
195 /// The full device-independent pipeline: constant folding, dead-node
196 /// elimination, and operator fusion (which can introduce fused
197 /// `com.microsoft` contrib ops such as `LayerNormalization`).
198 All,
199}
200
201impl OptimizationLevel {
202 /// Parse the `"optimization"` option value. Accepts `"none"`, `"basic"`,
203 /// and `"all"` (case-insensitive).
204 fn parse(value: &str) -> Option<Self> {
205 match value.trim().to_ascii_lowercase().as_str() {
206 "none" | "off" | "0" => Some(Self::None),
207 "basic" => Some(Self::Basic),
208 "all" => Some(Self::All),
209 _ => None,
210 }
211 }
212
213 /// The optimizer passes this level enables, in pipeline order. Empty for
214 /// [`OptimizationLevel::None`].
215 fn passes(self) -> Vec<Box<dyn onnx_runtime_optimizer::OptimizationPass>> {
216 use onnx_runtime_optimizer::{ConstantFolding, DeadNodeElimination, OpFusion};
217 match self {
218 Self::None => Vec::new(),
219 Self::Basic => vec![Box::new(ConstantFolding), Box::new(DeadNodeElimination)],
220 Self::All => vec![
221 Box::new(ConstantFolding),
222 Box::new(DeadNodeElimination),
223 Box::new(OpFusion::new()),
224 ],
225 }
226 }
227}
228
229/// Builder for advanced session configuration (§20.6).
230#[derive(Default)]
231pub struct SessionBuilder {
232 model_path: Option<PathBuf>,
233 model_bytes: Option<Vec<u8>>,
234 device: DevicePreference,
235 memory_limit: Option<usize>,
236 enable_profiling: bool,
237 warmup_shapes: Vec<WarmupShape>,
238 options: HashMap<String, String>,
239}
240
241impl SessionBuilder {
242 pub fn new() -> Self {
243 Self::default()
244 }
245
246 pub fn model(mut self, path: impl AsRef<Path>) -> Self {
247 self.model_path = Some(path.as_ref().to_path_buf());
248 self
249 }
250
251 pub fn model_bytes(mut self, bytes: &[u8]) -> Self {
252 self.model_bytes = Some(bytes.to_vec());
253 self
254 }
255
256 pub fn device(mut self, pref: DevicePreference) -> Self {
257 self.device = pref;
258 self
259 }
260
261 pub fn memory_limit(mut self, bytes: usize) -> Self {
262 self.memory_limit = Some(bytes);
263 self
264 }
265
266 pub fn profiling(mut self, enable: bool) -> Self {
267 self.enable_profiling = enable;
268 self
269 }
270
271 pub fn warmup(mut self, shapes: Vec<WarmupShape>) -> Self {
272 self.warmup_shapes = shapes;
273 self
274 }
275
276 /// Set a namespaced option. Unknown keys — and unknown values for a known
277 /// key — are rejected at [`Self::build`].
278 ///
279 /// # Recognized options
280 ///
281 /// | Key | Values | Default | Effect |
282 /// |-------------------------|------------------------------|----------|--------|
283 /// | `"optimization"` | `"none"`, `"basic"`, `"all"` | `"none"` | Graph optimization level (see [`OptimizationLevel`]). |
284 /// | `"ep.context_enable"` | `"0"`/`"1"`/`"false"`/`"true"` | `"0"` | Dump a `*_ctx.onnx` EPContext model after compile (§21.4 / §55.4). |
285 /// | `"ep.context_file_path"`| any path | `<orig>_ctx.onnx` | Output path for the generated context model. |
286 /// | `"ep.context_embed_mode"`| `"0"` (external) / `"1"` (embed) | `"1"` | How the compiled blob is stored in each EPContext node. |
287 ///
288 /// `"optimization"` = `"none"` (the default) leaves the loaded graph
289 /// untouched, so behavior is byte-identical to a runtime with no optimizer.
290 /// `"basic"` runs constant folding + dead-node elimination; `"all"` adds
291 /// operator fusion. When any pass runs, the session re-runs shape inference
292 /// on the rewritten graph before compiling so fused/introduced nodes get
293 /// inferred shapes.
294 pub fn option(mut self, key: &str, value: &str) -> Self {
295 self.options.insert(key.to_string(), value.to_string());
296 self
297 }
298
299 /// Parse every set session option in a single pass, rejecting any unknown
300 /// key or unparseable value up front (no silent compat shim — an
301 /// unrecognized key is a typo, never a no-op). Returns the resolved
302 /// [`OptimizationLevel`] and the EPContext dump config (§21.4 / §55.5)
303 /// driven by the `ep.context_*` keys.
304 ///
305 /// # Recognized keys
306 ///
307 /// * `"optimization"` → [`OptimizationLevel`] (`none` / `basic` / `all`).
308 /// * `"ep.context_enable"` → [`EpContextDumpConfig::enable`]
309 /// (`1`/`0`/`true`/`false`, case-insensitive).
310 /// * `"ep.context_file_path"` → [`EpContextDumpConfig::file_path`] (an empty
311 /// value clears it back to the `<orig>_ctx.onnx` default).
312 /// * `"ep.context_embed_mode"` → [`EpContextDumpConfig::embed_mode`]
313 /// (`0` external file / `1` embed; any other value is rejected).
314 fn parse_options(
315 options: &HashMap<String, String>,
316 ) -> Result<(OptimizationLevel, EpContextDumpConfig)> {
317 let mut level = OptimizationLevel::None;
318 let mut ctx = EpContextDumpConfig::default();
319 for (key, value) in options {
320 match key.as_str() {
321 "optimization" => {
322 level = OptimizationLevel::parse(value).ok_or_else(|| {
323 SessionError::InvalidOption {
324 key: key.clone(),
325 value: value.clone(),
326 expected: "none, basic, all".to_string(),
327 }
328 })?;
329 }
330 "ep.context_enable" => {
331 ctx.enable = parse_bool_option(key, value)?;
332 }
333 "ep.context_file_path" => {
334 // Empty/unset ⇒ None (fall back to `<orig>_ctx.onnx`).
335 ctx.file_path = if value.trim().is_empty() {
336 None
337 } else {
338 Some(PathBuf::from(value))
339 };
340 }
341 "ep.context_embed_mode" => {
342 ctx.embed_mode = parse_embed_mode(key, value)?;
343 }
344 // No compat shim: an unrecognized key is a typo, not a silent
345 // no-op.
346 _ => return Err(SessionError::UnknownOption { key: key.clone() }),
347 }
348 }
349 Ok((level, ctx))
350 }
351
352 /// Build the session: load → detect device → optimize → compile → allocate.
353 ///
354 /// The `optimize` stage is driven by the `"optimization"` session option and
355 /// defaults to [`OptimizationLevel::None`] (a no-op), so the default path is
356 /// byte-identical to loading straight into the executor. When optimization
357 /// is enabled the pipeline is:
358 ///
359 /// ```text
360 /// load (+ loader shape inference)
361 /// → run optimizer passes (constant-fold / DCE / fusion)
362 /// → re-run shape inference on the rewritten graph
363 /// → compile (kernel per node) → allocate
364 /// ```
365 ///
366 /// The re-inference step is essential: fusion can replace a multi-op
367 /// decomposition (e.g. the 9-op LayerNorm) with a single fused node whose
368 /// output has no inferred shape yet, and the compile/execute stages require
369 /// every value to carry a resolved shape.
370 ///
371 /// Device selection is CPU-only (`auto_detect` yields the CPU EP), and
372 /// "compile" resolves a kernel per node into the shape-keyed cache.
373 pub fn build(self) -> Result<InferenceSession> {
374 let (level, ep_context_config) = Self::parse_options(&self.options)?;
375
376 // `memory_limit`, `enable_profiling`, and non-CPU `device` preferences
377 // are accepted but not yet acted on in Phase 1 (CPU-only executor).
378 let _ = (self.device, self.memory_limit, self.enable_profiling);
379
380 let (mut graph, weights, model_dir) = match (self.model_path, self.model_bytes) {
381 (Some(path), _) => {
382 // The EPContext load path resolves `embed_mode=0` external blob
383 // paths relative to the model file's directory (§55.3), so
384 // retain it (same base dir the loader used for external data).
385 let model_dir = path
386 .parent()
387 .map(Path::to_path_buf)
388 .unwrap_or_else(|| PathBuf::from("."));
389 let (g, w) = onnx_runtime_loader::load_model_with_weights(path)?;
390 (g, w, model_dir)
391 }
392 (None, Some(bytes)) => {
393 let (g, w) = onnx_runtime_loader::load_model_bytes_with_weights(&bytes, ".")?;
394 (g, w, PathBuf::from("."))
395 }
396 (None, None) => return Err(SessionError::NoModelSource),
397 };
398
399 // Optimize stage. Off by default; only runs when a level is selected.
400 optimize_graph(&mut graph, level)?;
401
402 let mut session =
403 InferenceSession::from_parts(graph, weights, &model_dir, ep_context_config)?;
404 if !self.warmup_shapes.is_empty() {
405 session.warmup(&self.warmup_shapes)?;
406 }
407 Ok(session)
408 }
409}
410
411/// Parse a boolean-ish session-option value (§21.4). Accepts `1`/`0` and
412/// `true`/`false` (case-insensitive), mirroring how ORT's C API treats its
413/// `int`-typed `ep.context_enable` flag while also allowing the textual form.
414/// Any other value is a typo, surfaced as [`SessionError::InvalidOption`].
415fn parse_bool_option(key: &str, value: &str) -> Result<bool> {
416 match value.trim().to_ascii_lowercase().as_str() {
417 "1" | "true" => Ok(true),
418 "0" | "false" => Ok(false),
419 _ => Err(SessionError::InvalidOption {
420 key: key.to_string(),
421 value: value.to_string(),
422 expected: "0, 1, true, false".to_string(),
423 }),
424 }
425}
426
427/// Parse the `ep.context_embed_mode` option (§21.4): `0` = external sidecar
428/// file, `1` = embed the blob inline. Any other value is rejected with
429/// [`SessionError::InvalidOption`] (mirroring [`OptimizationLevel::parse`]'s
430/// fail-closed rejection rather than silently clamping).
431fn parse_embed_mode(key: &str, value: &str) -> Result<u8> {
432 match value.trim() {
433 "0" => Ok(0),
434 "1" => Ok(1),
435 _ => Err(SessionError::InvalidOption {
436 key: key.to_string(),
437 value: value.to_string(),
438 expected: "0, 1".to_string(),
439 }),
440 }
441}
442
443/// Run the optimizer passes selected by `level`, then re-run shape inference so
444/// any node fusion introduced (whose outputs the loader never saw) gets a fully
445/// inferred shape/dtype before compile.
446///
447/// A no-op when `level` is [`OptimizationLevel::None`] — the graph is returned
448/// untouched and no re-inference runs, keeping the default path byte-identical.
449fn optimize_graph(graph: &mut onnx_runtime_ir::Graph, level: OptimizationLevel) -> Result<()> {
450 let passes = level.passes();
451 if passes.is_empty() {
452 return Ok(());
453 }
454
455 onnx_runtime_optimizer::run_passes(
456 graph,
457 &passes,
458 &onnx_runtime_optimizer::PassContext::new(),
459 )?;
460
461 // Fusion emits fused ops in the `com.microsoft` contrib domain; make sure
462 // that domain is imported so shape-inference and kernel dispatch pick the
463 // contrib-registered rules (they register from opset 1, but recording the
464 // import keeps the graph self-consistent and future-proofs versioned rules).
465 graph
466 .opset_imports
467 .entry(onnx_runtime_optimizer::CONTRIB_DOMAIN.to_string())
468 .or_insert(1);
469
470 // Re-infer shapes over the rewritten graph: fused nodes' outputs (and any
471 // value whose producer changed) must be re-resolved before compile.
472 let registry = onnx_runtime_shape_inference::InferenceRegistry::default_registry();
473 let opset_imports = graph.opset_imports.clone();
474 registry.infer_graph(
475 graph,
476 &opset_imports,
477 onnx_runtime_shape_inference::MergePolicy::Permissive,
478 )?;
479
480 Ok(())
481}
482
483/// A loaded model ready to run inference (§20.2).
484pub struct InferenceSession {
485 inputs: Vec<IoMeta>,
486 outputs: Vec<IoMeta>,
487 exec: executor::Executor,
488 /// EPContext dump config parsed from the `ep.context_*` session options
489 /// (§21.4). Drives [`InferenceSession::export_ep_context`]; disabled by
490 /// default so an ordinary session never touches the dump path.
491 ep_context_config: EpContextDumpConfig,
492}
493
494fn io_meta(graph: &onnx_runtime_ir::Graph, values: &[onnx_runtime_ir::ValueId]) -> Vec<IoMeta> {
495 values
496 .iter()
497 .map(|&vid| {
498 let v = graph.value(vid);
499 IoMeta {
500 name: v.name.clone().unwrap_or_default(),
501 dtype: v.dtype,
502 shape: v.shape.clone(),
503 }
504 })
505 .collect()
506}
507
508impl InferenceSession {
509 /// Primary entry point: load a model with auto device detection.
510 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
511 Self::builder().model(path).build()
512 }
513
514 /// Load a model from an in-memory buffer.
515 pub fn load_bytes(bytes: &[u8]) -> Result<Self> {
516 Self::builder().model_bytes(bytes).build()
517 }
518
519 /// Build a session directly from an in-memory IR [`Graph`](onnx_runtime_ir::Graph).
520 ///
521 /// Initializer bytes are read from the graph's inline [`WeightRef`]s, so no
522 /// on-disk model or weight store is required. Useful for programmatically
523 /// constructed graphs and tests.
524 pub fn from_graph(graph: onnx_runtime_ir::Graph) -> Result<Self> {
525 // No on-disk model: `embed_mode=0` external EPContext blobs resolve
526 // relative to the current directory (consistent with the loader's
527 // in-memory `base_dir` default).
528 Self::from_parts(
529 graph,
530 std::sync::Arc::new(onnx_runtime_loader::WeightStore::new()),
531 Path::new("."),
532 EpContextDumpConfig::default(),
533 )
534 }
535
536 fn from_parts(
537 graph: onnx_runtime_ir::Graph,
538 weights: std::sync::Arc<onnx_runtime_loader::WeightStore>,
539 model_dir: &Path,
540 ep_context_config: EpContextDumpConfig,
541 ) -> Result<Self> {
542 let inputs = io_meta(&graph, &graph.inputs);
543 let outputs = io_meta(&graph, &graph.outputs);
544 let ep = executor::auto_detect_cpu_ep()?;
545
546 // EPContext consume path (§55.3): restore any pre-compiled EP contexts
547 // before building the executor. Dispatch is a pure `source`-key lookup
548 // over the session's registered EPs (Phase 1: the CPU EP only, which
549 // declares no `source` keys — so a model that carries EPContext nodes
550 // for an unloaded compiled EP fails with a clear `NoEpForContext`). The
551 // executor then bypasses these nodes (they are pre-compiled, never run
552 // as ordinary kernels).
553 let eps: [(
554 onnx_runtime_ep_api::EpId,
555 &dyn onnx_runtime_ep_api::ExecutionProvider,
556 ); 1] = [(onnx_runtime_ep_api::EpId(0), ep.as_ref())];
557 epcontext::load_ep_context_nodes(&graph, model_dir, &eps)?;
558
559 let exec = executor::Executor::build(graph, weights, ep)?;
560 Ok(Self {
561 inputs,
562 outputs,
563 exec,
564 ep_context_config,
565 })
566 }
567
568 /// Start a configuration builder.
569 pub fn builder() -> SessionBuilder {
570 SessionBuilder::new()
571 }
572
573 /// Run inference with named inputs, returning the graph outputs in order.
574 pub fn run(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<Tensor>> {
575 self.exec.run(inputs)
576 }
577
578 /// Input metadata.
579 pub fn inputs(&self) -> &[IoMeta] {
580 &self.inputs
581 }
582
583 /// Output metadata.
584 pub fn outputs(&self) -> &[IoMeta] {
585 &self.outputs
586 }
587
588 /// Kernel-cache statistics (§11.1); useful to observe warmup/run reuse.
589 pub fn cache_stats(&self) -> CacheStats {
590 self.exec.cache_stats()
591 }
592
593 /// Pre-compile kernels for common shapes to avoid first-inference latency
594 /// (§11.3). Phase-1 minimal: the compiled plan's shapes already key the
595 /// cache, so this repopulates it for the plan; `shapes` are validated to
596 /// name real inputs.
597 pub fn warmup(&mut self, shapes: &[WarmupShape]) -> Result<()> {
598 for ws in shapes {
599 if !self.inputs.iter().any(|m| m.name == ws.input_name) {
600 return Err(SessionError::InputNotFound {
601 name: ws.input_name.clone(),
602 });
603 }
604 }
605 self.exec.warmup()
606 }
607
608 /// The EPContext dump configuration parsed from the `ep.context_*` session
609 /// options (§21.4). Disabled by default.
610 pub fn ep_context_config(&self) -> &EpContextDumpConfig {
611 &self.ep_context_config
612 }
613
614 /// The session's (post-optimize) compiled graph.
615 ///
616 /// This is the graph the executor runs and the same one
617 /// [`Self::export_ep_context`] serialises — a caller identifying the
618 /// [`NodeId`](onnx_runtime_ir::NodeId)s of a compiled partition (the
619 /// [`CompiledPartition::covered_nodes`]) must read them from here so they
620 /// reference the exact nodes the exporter will splice out. This is the
621 /// compiler-integration seam: a real compiling EP inspects this graph to
622 /// choose the subgraphs it claims.
623 pub fn graph(&self) -> &onnx_runtime_ir::Graph {
624 self.exec.graph()
625 }
626
627 /// Export a `com.microsoft::EPContext` context-cache model for this session
628 /// (§55.4 dump path), driven by the `ep.context_*` session options
629 /// ([`Self::ep_context_config`]).
630 ///
631 /// `orig_path` is the source model path the default output location
632 /// (`<orig>_ctx.onnx`) is derived from when `ep.context_file_path` is unset.
633 /// `partitions` are the EP-compiled partitions to serialise — each names the
634 /// [`ExecutionProvider`](onnx_runtime_ep_api::ExecutionProvider) that
635 /// compiled it, so the driver pulls the blob + SDK version via
636 /// [`save_context`](onnx_runtime_ep_api::ExecutionProvider::save_context) and
637 /// the `source` key via
638 /// [`context_source_keys`](onnx_runtime_ep_api::ExecutionProvider::context_source_keys)
639 /// (§55.6 — nothing is hardcoded).
640 ///
641 /// When `ep.context_enable` is `false` (the default) this is a **no-op**: no
642 /// EP `save_context` is called and no files are written; it returns the path
643 /// it *would* have written to.
644 ///
645 /// # Compiler-integration seam
646 ///
647 /// The Phase-1 CPU EP has **no compile step**, so no real EP yet yields
648 /// [`CompiledPartition`]s — `partitions` is therefore supplied by the
649 /// caller (proven end-to-end with a mock compiling EP in the crate tests).
650 /// TODO(compiler): when a real compiling EP lands, collect its partitions
651 /// from the compile/placement stage and call this internally at build time
652 /// so a session created with `ep.context_enable=1` dumps automatically.
653 pub fn export_ep_context(
654 &self,
655 orig_path: &Path,
656 partitions: &[CompiledPartition],
657 ) -> Result<PathBuf> {
658 let model = EncoderModel::new(self.exec.graph()).with_weights(self.exec.weights().as_ref());
659 dump_session_ep_context(&model, orig_path, partitions, &self.ep_context_config)
660 }
661}
662
663/// Load a model. Auto-detects the best available hardware (§20.2).
664///
665/// This is the primary entry point — no configuration required.
666pub fn load(path: impl AsRef<Path>) -> Result<InferenceSession> {
667 InferenceSession::load(path)
668}
669
670#[cfg(test)]
671mod option_tests {
672 use super::*;
673
674 fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
675 pairs
676 .iter()
677 .map(|(k, v)| (k.to_string(), v.to_string()))
678 .collect()
679 }
680
681 fn level_of(pairs: &[(&str, &str)]) -> Result<OptimizationLevel> {
682 SessionBuilder::parse_options(&opts(pairs)).map(|(level, _)| level)
683 }
684
685 fn ctx_of(pairs: &[(&str, &str)]) -> Result<EpContextDumpConfig> {
686 SessionBuilder::parse_options(&opts(pairs)).map(|(_, ctx)| ctx)
687 }
688
689 #[test]
690 fn optimization_defaults_to_none_when_unset() {
691 assert_eq!(level_of(&[]).unwrap(), OptimizationLevel::None);
692 }
693
694 #[test]
695 fn optimization_parses_known_values() {
696 for (v, want) in [
697 ("none", OptimizationLevel::None),
698 ("off", OptimizationLevel::None),
699 ("BASIC", OptimizationLevel::Basic),
700 ("All", OptimizationLevel::All),
701 ] {
702 assert_eq!(level_of(&[("optimization", v)]).unwrap(), want, "value {v:?}");
703 }
704 }
705
706 #[test]
707 fn unknown_option_key_is_rejected() {
708 let err = level_of(&[("optimisation", "all")]).unwrap_err();
709 assert!(matches!(err, SessionError::UnknownOption { key } if key == "optimisation"));
710 }
711
712 #[test]
713 fn invalid_optimization_value_is_rejected() {
714 let err = level_of(&[("optimization", "aggressive")]).unwrap_err();
715 assert!(matches!(
716 err,
717 SessionError::InvalidOption { key, value, .. } if key == "optimization" && value == "aggressive"
718 ));
719 }
720
721 #[test]
722 fn none_level_selects_no_passes() {
723 assert!(OptimizationLevel::None.passes().is_empty());
724 assert_eq!(OptimizationLevel::Basic.passes().len(), 2);
725 assert_eq!(OptimizationLevel::All.passes().len(), 3);
726 }
727
728 // ── EPContext dump options (§21.4 / §55.5) ────────────────────────────────
729
730 #[test]
731 fn ep_context_defaults_to_disabled() {
732 let ctx = ctx_of(&[]).unwrap();
733 assert_eq!(ctx, EpContextDumpConfig::default());
734 assert!(!ctx.enable);
735 assert_eq!(ctx.file_path, None);
736 assert_eq!(ctx.embed_mode, 1);
737 }
738
739 #[test]
740 fn ep_context_enable_parses_bool_forms() {
741 for (v, want) in [
742 ("1", true),
743 ("0", false),
744 ("true", true),
745 ("TRUE", true),
746 ("false", false),
747 ("False", false),
748 ] {
749 let ctx = ctx_of(&[("ep.context_enable", v)]).unwrap();
750 assert_eq!(ctx.enable, want, "value {v:?}");
751 }
752 }
753
754 #[test]
755 fn ep_context_enable_rejects_garbage() {
756 let err = ctx_of(&[("ep.context_enable", "yes")]).unwrap_err();
757 assert!(matches!(
758 err,
759 SessionError::InvalidOption { key, value, .. }
760 if key == "ep.context_enable" && value == "yes"
761 ));
762 }
763
764 #[test]
765 fn ep_context_file_path_parses_and_empty_clears() {
766 let ctx = ctx_of(&[("ep.context_file_path", "/out/net_ctx.onnx")]).unwrap();
767 assert_eq!(ctx.file_path, Some(PathBuf::from("/out/net_ctx.onnx")));
768
769 // Empty value falls back to the `<orig>_ctx.onnx` default (None).
770 let ctx = ctx_of(&[("ep.context_file_path", "")]).unwrap();
771 assert_eq!(ctx.file_path, None);
772 }
773
774 #[test]
775 fn ep_context_embed_mode_parses_and_rejects() {
776 assert_eq!(ctx_of(&[("ep.context_embed_mode", "0")]).unwrap().embed_mode, 0);
777 assert_eq!(ctx_of(&[("ep.context_embed_mode", "1")]).unwrap().embed_mode, 1);
778
779 let err = ctx_of(&[("ep.context_embed_mode", "2")]).unwrap_err();
780 assert!(matches!(
781 err,
782 SessionError::InvalidOption { key, value, expected }
783 if key == "ep.context_embed_mode" && value == "2" && expected == "0, 1"
784 ));
785 }
786
787 #[test]
788 fn ep_context_options_combine_with_optimization() {
789 let (level, ctx) = SessionBuilder::parse_options(&opts(&[
790 ("optimization", "all"),
791 ("ep.context_enable", "1"),
792 ("ep.context_file_path", "/tmp/out_ctx.onnx"),
793 ("ep.context_embed_mode", "0"),
794 ]))
795 .unwrap();
796 assert_eq!(level, OptimizationLevel::All);
797 assert!(ctx.enable);
798 assert_eq!(ctx.file_path, Some(PathBuf::from("/tmp/out_ctx.onnx")));
799 assert_eq!(ctx.embed_mode, 0);
800 }
801}