Skip to main content

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