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
17// SessionError intentionally preserves rich, structured diagnostics in the
18// public API; boxing it would be an API and behavior change rather than a lint fix.
19#![allow(clippy::result_large_err)]
20// Executor calls mirror graph/operator contracts with independently meaningful inputs.
21#![allow(clippy::too_many_arguments)]
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use onnx_runtime_ir::{DataType, DeviceType, Shape};
27
28pub use epcontext::{
29    CompiledPartition, EpContextPlacement, dump_session_ep_context, load_ep_context_nodes,
30};
31pub use error::SessionError;
32pub use executor::{
33    CacheStats, CaptureDecline, CaptureDeclineReport, CapturePathKind, ControlFlowStats,
34    DeviceAllocationCounts, DeviceGraphCaptureResult, ExecutionProviderDecline,
35    ExecutionProviderFallbackReport, SeamReason, print_exec_phase_profile,
36};
37pub use onnx_runtime_loader::{
38    EpContextDumpConfig, EpContextPartition, Model as EncoderModel, ModelMetadata,
39};
40pub use tensor::{DeviceBindingTransferStats, DeviceIoBinding, Tensor, cpu_allocator};
41
42mod epcontext;
43mod executor;
44mod fp16_decode;
45pub mod sequence;
46mod tensor;
47
48/// A graph output produced by the runtime.
49#[derive(Debug)]
50pub enum SessionOutput {
51    Tensor(Tensor),
52    Sequence(sequence::SequenceValue),
53}
54
55impl SessionOutput {
56    pub fn as_tensor(&self) -> Option<&Tensor> {
57        match self {
58            Self::Tensor(tensor) => Some(tensor),
59            Self::Sequence(_) => None,
60        }
61    }
62
63    pub fn as_sequence(&self) -> Option<&sequence::SequenceValue> {
64        match self {
65            Self::Tensor(_) => None,
66            Self::Sequence(sequence) => Some(sequence),
67        }
68    }
69
70    pub fn into_tensor(self) -> Option<Tensor> {
71        match self {
72            Self::Tensor(tensor) => Some(tensor),
73            Self::Sequence(_) => None,
74        }
75    }
76
77    pub fn into_sequence(self) -> Option<sequence::SequenceValue> {
78        match self {
79            Self::Tensor(_) => None,
80            Self::Sequence(sequence) => Some(sequence),
81        }
82    }
83}
84
85/// Operator-set version associated with an operator dispatch failure.
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum OpsetVersion {
88    /// The model declares this version for the operator's domain.
89    Known(u64),
90    /// The model has no opset import for the operator's domain.
91    Undeclared,
92}
93
94impl std::fmt::Display for OpsetVersion {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::Known(version) => version.fmt(f),
98            Self::Undeclared => f.write_str("<undeclared>"),
99        }
100    }
101}
102
103mod error {
104    use super::OpsetVersion;
105
106    struct UnsupportedOpRemediation<'a> {
107        opset: OpsetVersion,
108        domain: &'a str,
109    }
110
111    impl std::fmt::Display for UnsupportedOpRemediation<'_> {
112        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113            if self.opset == OpsetVersion::Undeclared {
114                write!(
115                    f,
116                    "declare an opset_import for domain {:?} in the model, ",
117                    self.domain
118                )?;
119            }
120            f.write_str(
121                "enable another EP that supports this operator and opset, convert or decompose \
122                 the model operator, or file an nxrt issue with the model details",
123            )
124        }
125    }
126
127    fn unsupported_op_remediation(
128        opset: OpsetVersion,
129        domain: &str,
130    ) -> UnsupportedOpRemediation<'_> {
131        UnsupportedOpRemediation { opset, domain }
132    }
133
134    /// Errors produced by the session layer.
135    #[derive(Debug, thiserror::Error)]
136    pub enum SessionError {
137        #[error("session not initialized")]
138        NotInitialized,
139
140        #[error("input not found: {name}")]
141        InputNotFound { name: String },
142
143        #[error("unknown session option: {key}")]
144        UnknownOption { key: String },
145
146        #[error("invalid value {value:?} for session option {key:?}: expected one of {expected}")]
147        InvalidOption {
148            key: String,
149            value: String,
150            expected: String,
151        },
152
153        #[error("no model source: set a path or bytes on the builder")]
154        NoModelSource,
155
156        #[error("execution provider unavailable: {0}")]
157        ExecutionProviderUnavailable(String),
158
159        #[error(
160            "CUDA execution required by ONNX_GENAI_REQUIRE_CUDA=1, but CPU fallback is needed: \
161             {unsupported_nodes}"
162        )]
163        HeterogeneousPlacementRequired { unsupported_nodes: String },
164
165        #[error(
166            "unsupported operator {domain}::{op_type}: no available execution provider has a \
167             kernel; node {node}, opset {opset}; decline reason: {reason}; consulted execution \
168             providers (priority order): {execution_providers}. To fix: {remediation}",
169            remediation = unsupported_op_remediation(*.opset, .domain)
170        )]
171        UnsupportedOp {
172            op_type: String,
173            domain: String,
174            node: String,
175            opset: OpsetVersion,
176            reason: String,
177            execution_providers: String,
178        },
179
180        #[error("value has a non-static (symbolic) shape and no binding to resolve it: {value}")]
181        DynamicShape { value: String },
182
183        #[error(
184            "symbol {symbol} bound to conflicting sizes {first} and {second} across bound inputs"
185        )]
186        SymbolConflict {
187            symbol: String,
188            first: usize,
189            second: usize,
190        },
191
192        #[error("input {name}: rank mismatch (graph declares rank {expected}, got {got})")]
193        RankMismatch {
194            name: String,
195            expected: usize,
196            got: usize,
197        },
198
199        #[error("no inferred shape for value {value} produced by op {op}")]
200        UnresolvedShape { value: String, op: String },
201
202        #[error("shape element count overflows usize for value {value} (dims {dims:?})")]
203        ShapeOverflow { value: String, dims: Vec<usize> },
204
205        #[error(
206            "op {op} produced {got} data-dependent output shape(s) but has {expected} output(s)"
207        )]
208        OutputShapeCountMismatch {
209            op: String,
210            expected: usize,
211            got: usize,
212        },
213
214        #[error(
215            "runtime broadcast shape resolution failed for node {node} ({domain}::{op_type}): \
216             concrete input shapes {input_shapes:?} are not broadcast-compatible, so no valid \
217             elementwise output shape exists. To fix: update the model or runtime inputs so each \
218             aligned dimension is equal or one of them is 1"
219        )]
220        RuntimeBroadcastIncompatible {
221            node: String,
222            domain: String,
223            op_type: String,
224            input_shapes: Vec<Vec<usize>>,
225        },
226
227        #[error("input {name}: dtype mismatch (expected {expected}, got {got})")]
228        DtypeMismatch {
229            name: String,
230            expected: String,
231            got: String,
232        },
233
234        #[error("input {name}: shape mismatch (expected {expected:?}, got {got:?})")]
235        ShapeMismatch {
236            name: String,
237            expected: Vec<usize>,
238            got: Vec<usize>,
239        },
240
241        #[error("internal executor error: {0}")]
242        Internal(String),
243
244        #[error("Sequence op {op}: {reason}")]
245        SequenceOp { op: String, reason: String },
246
247        #[error("control-flow op {op}: {reason}")]
248        ControlFlow { op: String, reason: String },
249
250        #[error(
251            "EPContext reference node (main_context=0) has no matching primary \
252             (source={source_key:?}, partition_name={partition_name:?})"
253        )]
254        DanglingEpContext {
255            source_key: Option<String>,
256            partition_name: Option<String>,
257        },
258
259        #[error(transparent)]
260        Load(#[from] onnx_runtime_loader::LoaderError),
261
262        #[error(transparent)]
263        Ep(#[from] onnx_runtime_ep_api::EpError),
264
265        #[error(transparent)]
266        Ir(#[from] onnx_runtime_ir::IrError),
267
268        #[error(transparent)]
269        Graph(#[from] onnx_runtime_ir::GraphError),
270
271        #[error(transparent)]
272        Optimize(#[from] onnx_runtime_optimizer::OptimizerError),
273
274        #[error(transparent)]
275        ShapeInfer(#[from] onnx_runtime_shape_inference::ShapeInferError),
276    }
277
278    impl SessionError {
279        pub(crate) fn unsupported_op(
280            node: &onnx_runtime_ir::Node,
281            node_id: onnx_runtime_ir::NodeId,
282            opset: u64,
283            execution_providers: impl Into<String>,
284            reason: impl Into<String>,
285        ) -> Self {
286            let domain = if node.domain.is_empty() {
287                "ai.onnx".to_string()
288            } else {
289                node.domain.clone()
290            };
291            let node_display = if node.name.is_empty() {
292                format!("<unnamed node #{}>", node_id.0)
293            } else {
294                format!("{:?}", node.name)
295            };
296            let opset = if opset == u64::MAX {
297                OpsetVersion::Undeclared
298            } else {
299                OpsetVersion::Known(opset)
300            };
301            Self::UnsupportedOp {
302                op_type: node.op_type.clone(),
303                domain,
304                node: node_display,
305                opset,
306                reason: reason.into(),
307                execution_providers: execution_providers.into(),
308            }
309        }
310    }
311
312    /// Session `Result` alias.
313    pub type Result<T> = std::result::Result<T, SessionError>;
314}
315
316use error::Result;
317
318/// Metadata describing a model input or output (§20.2).
319#[derive(Clone, Debug)]
320pub struct IoMeta {
321    pub name: String,
322    pub dtype: DataType,
323    pub shape: Shape,
324}
325
326/// Intent-based device preference (§20.4). The runtime maps this to concrete
327/// EPs during `build`.
328#[derive(Clone, Debug, Default, PartialEq, Eq)]
329pub enum DevicePreference {
330    /// Pick the best available device automatically.
331    #[default]
332    Auto,
333    /// Prefer CPU execution.
334    Cpu,
335    /// Prefer a GPU / accelerator, optionally by ordinal.
336    Gpu { index: Option<u32> },
337    /// Pin to a specific device class + ordinal.
338    Explicit { device_type: DeviceType, index: u32 },
339}
340
341/// A shape to pre-compile kernels for at session init (§11.3).
342#[derive(Clone, Debug)]
343pub struct WarmupShape {
344    pub input_name: String,
345    pub shape: Vec<usize>,
346}
347
348/// Decoder-wide numeric precision for the session's decode graph.
349///
350/// This is a generic, model-agnostic knob selected via
351/// [`SessionBuilder::decode_precision`]. The default,
352/// [`DecodePrecision::Model`], runs the graph exactly as authored, so default
353/// runtime behaviour is byte-identical to a build with no precision knob at all.
354///
355/// [`DecodePrecision::Fp16`] requests a whole-decoder fp32→fp16 rewrite (see
356/// [`fp16_decode`]). It only takes effect on a GPU device and only for an
357/// fp32-activation int4/block-32 quantized decoder (fp32-scale `MatMulNBits`);
358/// for every other model — including native fp16-activation models — it is a
359/// strict no-op, leaving the graph bit-identical.
360#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
361pub enum DecodePrecision {
362    /// Run the decoder at the precision authored in the model graph (default).
363    #[default]
364    Model,
365    /// Cast an fp32-activation int4/block-32 decoder to a fully fp16 graph so a
366    /// GPU backend runs it through the fp16-fused decode kernels. No-op unless
367    /// the session targets a GPU and the graph is fp32-activation quantized.
368    Fp16,
369}
370
371/// Graph-optimization level for the session's `optimize` pipeline stage
372/// (`docs/ORT2.md` §18). Selected via the generic `"optimization"` session
373/// option (see [`SessionBuilder::option`]).
374///
375/// The default is [`OptimizationLevel::None`]: with optimization off the graph
376/// reaches the executor exactly as the loader produced it, so default runtime
377/// behavior is byte-identical to a build with no optimizer wired in at all.
378///
379/// This is a generic, model-agnostic knob — no level ever special-cases a model
380/// name or op. Higher levels simply enable more of the device-independent pass
381/// pipeline from [`onnx_runtime_optimizer`].
382#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
383pub enum OptimizationLevel {
384    /// No passes — the `optimize` stage is a no-op (default).
385    #[default]
386    None,
387    /// Structure-preserving passes only: constant folding then dead-node
388    /// elimination. No operator fusion, so the op set the executor sees is a
389    /// subset of the loaded graph's.
390    Basic,
391    /// The full device-independent pipeline: constant folding, dead-node
392    /// elimination, and operator fusion (which can introduce fused
393    /// `com.microsoft` contrib ops such as `LayerNormalization`).
394    All,
395}
396
397impl OptimizationLevel {
398    /// Parse the `"optimization"` option value. Accepts `"none"`, `"basic"`,
399    /// and `"all"` (case-insensitive).
400    fn parse(value: &str) -> Option<Self> {
401        match value.trim().to_ascii_lowercase().as_str() {
402            "none" | "off" | "0" => Some(Self::None),
403            "basic" => Some(Self::Basic),
404            "all" => Some(Self::All),
405            _ => None,
406        }
407    }
408
409    /// The optimizer passes this level enables, in pipeline order. Empty for
410    /// [`OptimizationLevel::None`].
411    fn passes(self) -> Vec<Box<dyn onnx_runtime_optimizer::OptimizationPass>> {
412        use onnx_runtime_optimizer::{ConstantFolding, DeadNodeElimination, OpFusion};
413        match self {
414            Self::None => Vec::new(),
415            Self::Basic => vec![Box::new(ConstantFolding), Box::new(DeadNodeElimination)],
416            Self::All => vec![
417                Box::new(ConstantFolding),
418                Box::new(DeadNodeElimination),
419                Box::new(OpFusion::new()),
420            ],
421        }
422    }
423}
424
425/// Builder for advanced session configuration (§20.6).
426#[derive(Default)]
427pub struct SessionBuilder {
428    model_path: Option<PathBuf>,
429    model_bytes: Option<Vec<u8>>,
430    device: DevicePreference,
431    execution_provider: Option<std::sync::Arc<dyn onnx_runtime_ep_api::ExecutionProvider>>,
432    memory_limit: Option<usize>,
433    enable_profiling: bool,
434    warmup_shapes: Vec<WarmupShape>,
435    decode_precision: DecodePrecision,
436    options: HashMap<String, String>,
437}
438
439impl SessionBuilder {
440    pub fn new() -> Self {
441        Self::default()
442    }
443
444    pub fn model(mut self, path: impl AsRef<Path>) -> Self {
445        self.model_path = Some(path.as_ref().to_path_buf());
446        self
447    }
448
449    pub fn model_bytes(mut self, bytes: &[u8]) -> Self {
450        self.model_bytes = Some(bytes.to_vec());
451        self
452    }
453
454    pub fn device(mut self, pref: DevicePreference) -> Self {
455        self.device = pref;
456        self
457    }
458
459    /// Use an explicitly constructed execution provider instead of device auto-selection.
460    pub fn execution_provider(
461        mut self,
462        execution_provider: std::sync::Arc<dyn onnx_runtime_ep_api::ExecutionProvider>,
463    ) -> Self {
464        self.execution_provider = Some(execution_provider);
465        self
466    }
467
468    pub fn memory_limit(mut self, bytes: usize) -> Self {
469        self.memory_limit = Some(bytes);
470        self
471    }
472
473    pub fn profiling(mut self, enable: bool) -> Self {
474        self.enable_profiling = enable;
475        self
476    }
477
478    pub fn warmup(mut self, shapes: Vec<WarmupShape>) -> Self {
479        self.warmup_shapes = shapes;
480        self
481    }
482
483    /// Select the decoder-wide numeric precision (see [`DecodePrecision`]).
484    /// Defaults to [`DecodePrecision::Model`] (graph as authored); selecting
485    /// [`DecodePrecision::Fp16`] opts into the fp32→fp16 decode rewrite, which
486    /// only takes effect on a GPU fp32-activation quantized decoder.
487    pub fn decode_precision(mut self, precision: DecodePrecision) -> Self {
488        self.decode_precision = precision;
489        self
490    }
491
492    /// Set a namespaced option. Unknown keys — and unknown values for a known
493    /// key — are rejected at [`Self::build`].
494    ///
495    /// # Recognized options
496    ///
497    /// | Key                     | Values                       | Default  | Effect |
498    /// |-------------------------|------------------------------|----------|--------|
499    /// | `"optimization"`        | `"none"`, `"basic"`, `"all"` | `"none"` | Graph optimization level (see [`OptimizationLevel`]). |
500    /// | `"ep.context_enable"`   | `"0"`/`"1"`/`"false"`/`"true"` | `"0"`  | Dump a `*_ctx.onnx` EPContext model after compile (§21.4 / §55.4). |
501    /// | `"ep.context_file_path"`| any path                     | `<orig>_ctx.onnx` | Output path for the generated context model. |
502    /// | `"ep.context_embed_mode"`| `"0"` (external) / `"1"` (embed) | `"1"` | How the compiled blob is stored in each EPContext node. |
503    ///
504    /// `"optimization"` = `"none"` (the default) leaves the loaded graph
505    /// untouched, so behavior is byte-identical to a runtime with no optimizer.
506    /// `"basic"` runs constant folding + dead-node elimination; `"all"` adds
507    /// operator fusion. When any pass runs, the session re-runs shape inference
508    /// on the rewritten graph before compiling so fused/introduced nodes get
509    /// inferred shapes.
510    pub fn option(mut self, key: &str, value: &str) -> Self {
511        self.options.insert(key.to_string(), value.to_string());
512        self
513    }
514
515    /// Parse every set session option in a single pass, rejecting any unknown
516    /// key or unparseable value up front (no silent compat shim — an
517    /// unrecognized key is a typo, never a no-op). Returns the resolved
518    /// [`OptimizationLevel`] and the EPContext dump config (§21.4 / §55.5)
519    /// driven by the `ep.context_*` keys.
520    ///
521    /// # Recognized keys
522    ///
523    /// * `"optimization"` → [`OptimizationLevel`] (`none` / `basic` / `all`).
524    /// * `"ep.context_enable"` → [`EpContextDumpConfig::enable`]
525    ///   (`1`/`0`/`true`/`false`, case-insensitive).
526    /// * `"ep.context_file_path"` → [`EpContextDumpConfig::file_path`] (an empty
527    ///   value clears it back to the `<orig>_ctx.onnx` default).
528    /// * `"ep.context_embed_mode"` → [`EpContextDumpConfig::embed_mode`]
529    ///   (`0` external file / `1` embed; any other value is rejected).
530    fn parse_options(
531        options: &HashMap<String, String>,
532    ) -> Result<(OptimizationLevel, EpContextDumpConfig)> {
533        let mut level = OptimizationLevel::None;
534        let mut ctx = EpContextDumpConfig::default();
535        for (key, value) in options {
536            match key.as_str() {
537                "optimization" => {
538                    level = OptimizationLevel::parse(value).ok_or_else(|| {
539                        SessionError::InvalidOption {
540                            key: key.clone(),
541                            value: value.clone(),
542                            expected: "none, basic, all".to_string(),
543                        }
544                    })?;
545                }
546                "ep.context_enable" => {
547                    ctx.enable = parse_bool_option(key, value)?;
548                }
549                "ep.context_file_path" => {
550                    // Empty/unset ⇒ None (fall back to `<orig>_ctx.onnx`).
551                    ctx.file_path = if value.trim().is_empty() {
552                        None
553                    } else {
554                        Some(PathBuf::from(value))
555                    };
556                }
557                "ep.context_embed_mode" => {
558                    ctx.embed_mode = parse_embed_mode(key, value)?;
559                }
560                // No compat shim: an unrecognized key is a typo, not a silent
561                // no-op.
562                _ => return Err(SessionError::UnknownOption { key: key.clone() }),
563            }
564        }
565        Ok((level, ctx))
566    }
567
568    /// Build the session: load → detect device → optimize → compile → allocate.
569    ///
570    /// The `optimize` stage is driven by the `"optimization"` session option and
571    /// defaults to [`OptimizationLevel::None`] (a no-op), so the default path is
572    /// byte-identical to loading straight into the executor. When optimization
573    /// is enabled the pipeline is:
574    ///
575    /// ```text
576    /// load (+ loader shape inference)
577    ///   → run optimizer passes (constant-fold / DCE / fusion)
578    ///   → re-run shape inference on the rewritten graph
579    ///   → compile (kernel per node) → allocate
580    /// ```
581    ///
582    /// The re-inference step is essential: fusion can replace a multi-op
583    /// decomposition (e.g. the 9-op LayerNorm) with a single fused node whose
584    /// output has no inferred shape yet, and the compile/execute stages require
585    /// every value to carry a resolved shape.
586    ///
587    /// Device selection keeps CPU as the default and selects CUDA only when
588    /// explicitly requested in a CUDA-enabled build. "Compile" resolves a
589    /// kernel per node into the shape-keyed cache.
590    pub fn build(self) -> Result<InferenceSession> {
591        let (level, ep_context_config) = Self::parse_options(&self.options)?;
592
593        // Memory limits and profiling remain reserved builder intents.
594        let _ = (self.memory_limit, self.enable_profiling);
595
596        let (mut graph, weights, model_dir, model_metadata) =
597            match (self.model_path, self.model_bytes) {
598                (Some(path), _) => {
599                    // The EPContext load path resolves `embed_mode=0` external blob
600                    // paths relative to the model file's directory (§55.3), so
601                    // retain it (same base dir the loader used for external data).
602                    let model_dir = path
603                        .parent()
604                        .map(Path::to_path_buf)
605                        .unwrap_or_else(|| PathBuf::from("."));
606                    let bytes = onnx_runtime_loader::read_model_binary(&path)?;
607                    let metadata = model_metadata_from_bytes(&bytes)?;
608                    let (g, w) =
609                        onnx_runtime_loader::load_model_bytes_with_weights(&bytes, &model_dir)?;
610                    (g, w, model_dir, metadata)
611                }
612                (None, Some(bytes)) => {
613                    let metadata = model_metadata_from_bytes(&bytes)?;
614                    let (g, w) = onnx_runtime_loader::load_model_bytes_with_weights(&bytes, ".")?;
615                    (g, w, PathBuf::from("."), metadata)
616                }
617                (None, None) => return Err(SessionError::NoModelSource),
618            };
619
620        // Decoder precision rewrite (opt-in). Applied here — on the freshly
621        // loaded graph, before the I/O signature (`IoMeta`) is computed and
622        // before EP optimization — so the KV/logits buffers and the executor
623        // graph agree on the rewritten dtype. A strict no-op for the default
624        // `DecodePrecision::Model`, for non-GPU devices, and for any graph that
625        // is not an fp32-activation quantized decoder, so the default path and
626        // native fp16 models stay bit-identical.
627        fp16_decode::maybe_convert_decode_fp16(
628            &mut graph,
629            &weights,
630            self.decode_precision,
631            device_preference_is_gpu(&self.device),
632        );
633
634        // Optimize stage. Off by default; only runs when a level is selected.
635        optimize_graph(&mut graph, level)?;
636        let ep = match self.execution_provider {
637            Some(ep) => ep,
638            None => select_execution_provider(&self.device)?,
639        };
640
641        let mut session = InferenceSession::from_parts(
642            graph,
643            weights,
644            &model_dir,
645            ep_context_config,
646            model_metadata,
647            ep,
648        )?;
649        if !self.warmup_shapes.is_empty() {
650            session.warmup(&self.warmup_shapes)?;
651        }
652        Ok(session)
653    }
654}
655
656/// Whether a [`DevicePreference`] targets a GPU / accelerator (non-host) device.
657/// Used to gate GPU-only graph rewrites such as the fp16 decode precision mode.
658fn device_preference_is_gpu(preference: &DevicePreference) -> bool {
659    match preference {
660        DevicePreference::Gpu { .. } => true,
661        DevicePreference::Explicit { device_type, .. } => !device_type.is_host_accessible(),
662        DevicePreference::Auto | DevicePreference::Cpu => false,
663    }
664}
665
666fn select_execution_provider(
667    preference: &DevicePreference,
668) -> Result<std::sync::Arc<dyn onnx_runtime_ep_api::ExecutionProvider>> {
669    match preference {
670        // Keep the zero-config/default behavior CPU-only. CUDA is an explicit
671        // opt-in until heterogeneous placement and fallback exist.
672        DevicePreference::Auto | DevicePreference::Cpu => executor::auto_detect_cpu_ep(),
673        DevicePreference::Explicit {
674            device_type: DeviceType::Cpu,
675            index: 0,
676        } => executor::auto_detect_cpu_ep(),
677        DevicePreference::Gpu { index } => cuda_execution_provider(index.unwrap_or(0)),
678        DevicePreference::Explicit {
679            device_type: DeviceType::Cuda,
680            index,
681        } => cuda_execution_provider(*index),
682        DevicePreference::Explicit { device_type, index } => {
683            Err(SessionError::ExecutionProviderUnavailable(format!(
684                "{device_type:?}:{index} is not implemented by onnx-runtime-session"
685            )))
686        }
687    }
688}
689
690#[cfg(feature = "cuda")]
691fn cuda_execution_provider(
692    index: u32,
693) -> Result<std::sync::Arc<dyn onnx_runtime_ep_api::ExecutionProvider>> {
694    let mut ep = onnx_runtime_ep_cuda::CudaExecutionProvider::new(index)?;
695    onnx_runtime_ep_api::ExecutionProvider::initialize(&mut ep, &Default::default())?;
696    Ok(std::sync::Arc::new(ep))
697}
698
699#[cfg(not(feature = "cuda"))]
700fn cuda_execution_provider(
701    index: u32,
702) -> Result<std::sync::Arc<dyn onnx_runtime_ep_api::ExecutionProvider>> {
703    Err(SessionError::ExecutionProviderUnavailable(format!(
704        "CUDA:{index} requested, but onnx-runtime-session was built without the `cuda` feature"
705    )))
706}
707
708fn model_metadata_from_bytes(bytes: &[u8]) -> Result<ModelMetadata> {
709    let model = onnx_runtime_loader::proto::decode_model(bytes)?;
710    Ok(ModelMetadata {
711        ir_version: model.ir_version,
712        producer_name: model.producer_name,
713        producer_version: model.producer_version,
714        domain: model.domain,
715        model_version: model.model_version,
716        doc_string: (!model.doc_string.is_empty()).then_some(model.doc_string),
717        graph_name: model.graph.map(|graph| graph.name).unwrap_or_default(),
718        metadata_props: model
719            .metadata_props
720            .into_iter()
721            .map(|entry| (entry.key, entry.value))
722            .collect(),
723    })
724}
725
726/// Parse a boolean-ish session-option value (§21.4). Accepts `1`/`0` and
727/// `true`/`false` (case-insensitive), mirroring how ORT's C API treats its
728/// `int`-typed `ep.context_enable` flag while also allowing the textual form.
729/// Any other value is a typo, surfaced as [`SessionError::InvalidOption`].
730fn parse_bool_option(key: &str, value: &str) -> Result<bool> {
731    match value.trim().to_ascii_lowercase().as_str() {
732        "1" | "true" => Ok(true),
733        "0" | "false" => Ok(false),
734        _ => Err(SessionError::InvalidOption {
735            key: key.to_string(),
736            value: value.to_string(),
737            expected: "0, 1, true, false".to_string(),
738        }),
739    }
740}
741
742/// Parse the `ep.context_embed_mode` option (§21.4): `0` = external sidecar
743/// file, `1` = embed the blob inline. Any other value is rejected with
744/// [`SessionError::InvalidOption`] (mirroring [`OptimizationLevel::parse`]'s
745/// fail-closed rejection rather than silently clamping).
746fn parse_embed_mode(key: &str, value: &str) -> Result<u8> {
747    match value.trim() {
748        "0" => Ok(0),
749        "1" => Ok(1),
750        _ => Err(SessionError::InvalidOption {
751            key: key.to_string(),
752            value: value.to_string(),
753            expected: "0, 1".to_string(),
754        }),
755    }
756}
757
758/// Run the optimizer passes selected by `level`, then re-run shape inference so
759/// any node fusion introduced (whose outputs the loader never saw) gets a fully
760/// inferred shape/dtype before compile.
761///
762/// A no-op when `level` is [`OptimizationLevel::None`] — the graph is returned
763/// untouched and no re-inference runs, keeping the default path byte-identical.
764fn optimize_graph(graph: &mut onnx_runtime_ir::Graph, level: OptimizationLevel) -> Result<()> {
765    let passes = level.passes();
766    if passes.is_empty() {
767        return Ok(());
768    }
769
770    onnx_runtime_optimizer::run_passes(
771        graph,
772        &passes,
773        &onnx_runtime_optimizer::PassContext::new(),
774    )?;
775
776    // Fusion emits fused ops in the `com.microsoft` contrib domain; make sure
777    // that domain is imported so shape-inference and kernel dispatch pick the
778    // contrib-registered rules (they register from opset 1, but recording the
779    // import keeps the graph self-consistent and future-proofs versioned rules).
780    graph
781        .opset_imports
782        .entry(onnx_runtime_optimizer::CONTRIB_DOMAIN.to_string())
783        .or_insert(1);
784
785    // Re-infer shapes over the rewritten graph: fused nodes' outputs (and any
786    // value whose producer changed) must be re-resolved before compile.
787    let registry = onnx_runtime_shape_inference::InferenceRegistry::default_registry();
788    let opset_imports = graph.opset_imports.clone();
789    registry.infer_graph(
790        graph,
791        &opset_imports,
792        onnx_runtime_shape_inference::MergePolicy::Permissive,
793    )?;
794
795    Ok(())
796}
797
798/// A loaded model ready to run inference (§20.2).
799pub struct InferenceSession {
800    inputs: Vec<IoMeta>,
801    outputs: Vec<IoMeta>,
802    model_metadata: ModelMetadata,
803    exec: executor::Executor,
804    /// EPContext dump config parsed from the `ep.context_*` session options
805    /// (§21.4). Drives [`InferenceSession::export_ep_context`]; disabled by
806    /// default so an ordinary session never touches the dump path.
807    ep_context_config: EpContextDumpConfig,
808}
809
810fn io_meta(graph: &onnx_runtime_ir::Graph, values: &[onnx_runtime_ir::ValueId]) -> Vec<IoMeta> {
811    values
812        .iter()
813        .map(|&vid| {
814            let v = graph.value(vid);
815            IoMeta {
816                name: v.name.clone().unwrap_or_default(),
817                dtype: v.dtype,
818                shape: v.shape.clone(),
819            }
820        })
821        .collect()
822}
823
824impl InferenceSession {
825    /// Primary entry point: load a model with auto device detection.
826    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
827        Self::builder().model(path).build()
828    }
829
830    /// Load a model from an in-memory buffer.
831    pub fn load_bytes(bytes: &[u8]) -> Result<Self> {
832        Self::builder().model_bytes(bytes).build()
833    }
834
835    /// Build a session directly from an in-memory IR [`Graph`](onnx_runtime_ir::Graph).
836    ///
837    /// Initializer bytes are read from the graph's inline [`WeightRef`]s, so no
838    /// on-disk model or weight store is required. Useful for programmatically
839    /// constructed graphs and tests.
840    pub fn from_graph(graph: onnx_runtime_ir::Graph) -> Result<Self> {
841        // No on-disk model: `embed_mode=0` external EPContext blobs resolve
842        // relative to the current directory (consistent with the loader's
843        // in-memory `base_dir` default).
844        Self::from_parts(
845            graph,
846            std::sync::Arc::new(onnx_runtime_loader::WeightStore::new()),
847            Path::new("."),
848            EpContextDumpConfig::default(),
849            ModelMetadata::default(),
850            executor::auto_detect_cpu_ep()?,
851        )
852    }
853
854    fn from_parts(
855        graph: onnx_runtime_ir::Graph,
856        weights: std::sync::Arc<onnx_runtime_loader::WeightStore>,
857        model_dir: &Path,
858        ep_context_config: EpContextDumpConfig,
859        model_metadata: ModelMetadata,
860        ep: std::sync::Arc<dyn onnx_runtime_ep_api::ExecutionProvider>,
861    ) -> Result<Self> {
862        // Establish the canonical-domain invariant for programmatically built
863        // graphs (the loader already normalizes at proto-materialization time):
864        // the default ONNX domain is `""`, never `"ai.onnx"`. The executor and
865        // validators rely on this, comparing `domain.is_empty()` directly.
866        let mut graph = graph;
867        graph.normalize_domains();
868
869        onnx_runtime_loader::validate_model(&graph)?;
870
871        let inputs = io_meta(&graph, &graph.inputs);
872        let outputs = io_meta(&graph, &graph.outputs);
873        // EPContext consume path (§55.3): restore any pre-compiled EP contexts
874        // before building the executor. Dispatch is a pure `source`-key lookup
875        // over the session's selected EP, so a model carrying EPContext nodes
876        // for an unloaded compiled EP fails with a clear `NoEpForContext`. The
877        // executor then bypasses these nodes (they are pre-compiled, never run
878        // as ordinary kernels).
879        let eps: [(
880            onnx_runtime_ep_api::EpId,
881            &dyn onnx_runtime_ep_api::ExecutionProvider,
882        ); 1] = [(onnx_runtime_ep_api::EpId(0), ep.as_ref())];
883        epcontext::load_ep_context_nodes(&graph, model_dir, &eps)?;
884
885        let exec = executor::Executor::build(graph, weights, ep)?;
886        Ok(Self {
887            inputs,
888            outputs,
889            model_metadata,
890            exec,
891            ep_context_config,
892        })
893    }
894
895    /// Start a configuration builder.
896    pub fn builder() -> SessionBuilder {
897        SessionBuilder::new()
898    }
899
900    /// Run inference with named inputs, returning the graph outputs in order.
901    pub fn run(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<Tensor>> {
902        self.exec.run(inputs)
903    }
904
905    /// Attach the shared runtime trace context. When enabled, the executor opens
906    /// one span per executed op so kernels can attach kernel-variant and
907    /// capture-rejection reasons to a live span. Defaults to a disabled no-op
908    /// context, so untraced runs pay only a single relaxed atomic load per op.
909    pub fn set_trace_context(&mut self, trace: onnx_runtime_tracer::TraceContext) {
910        self.exec.set_trace_context(trace);
911    }
912
913    /// Run inference and preserve tensor or sequence graph-output types.
914    pub fn run_outputs(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<SessionOutput>> {
915        self.exec.run_outputs(inputs)
916    }
917
918    /// F5 Stage 1 decode-plan memo activity counters `(primed, rebuilt, replayed,
919    /// ineligible)` over this session's lifetime. `replayed > 0` after a decode
920    /// run proves the memo actually engaged on the real (persistent-KV-binding)
921    /// path; the coordinator's on-model A/B reads this to reject a vacuous pass.
922    pub fn decode_memo_counts(&self) -> (u64, u64, u64, u64) {
923        self.exec.decode_memo_counts()
924    }
925
926    /// F5 Stage 2 view-plan activity counters `(views_reused, dispatch_elided)`
927    /// over this session's lifetime. Both `> 0` after a decode run prove the
928    /// invariant zero-copy view reuse and pure-view dispatch elision actually
929    /// fired on the real path (not a vacuous pass); an on-model A/B reads this
930    /// alongside [`Self::decode_memo_counts`].
931    pub fn decode_view_plan_counts(&self) -> (u64, u64) {
932        self.exec.decode_view_plan_counts()
933    }
934
935    /// Run with persistent device allocations supplying graph inputs and,
936    /// optionally, aliasing graph outputs. Bound outputs are returned as `None`
937    /// because their bytes remain resident in the caller-owned allocation.
938    pub fn run_with_device_bindings(
939        &mut self,
940        inputs: &[(&str, &Tensor)],
941        bindings: &mut [DeviceIoBinding],
942    ) -> Result<Vec<Option<Tensor>>> {
943        self.exec.run_with_device_bindings(inputs, bindings)
944    }
945
946    /// Allocate a persistent buffer on this session's execution device.
947    pub fn allocate_device_binding(
948        &self,
949        input_name: impl Into<String>,
950        output_name: Option<impl Into<String>>,
951        dtype: DataType,
952        physical_shape: Vec<usize>,
953        logical_shape: Vec<usize>,
954    ) -> Result<DeviceIoBinding> {
955        self.exec.allocate_device_binding(
956            input_name.into(),
957            output_name.map(Into::into),
958            dtype,
959            physical_shape,
960            logical_shape,
961        )
962    }
963
964    /// Allocate a persistent buffer for a graph output without also binding it
965    /// as an input.
966    pub fn allocate_device_output_binding(
967        &self,
968        output_name: impl Into<String>,
969        dtype: DataType,
970        physical_shape: Vec<usize>,
971        logical_shape: Vec<usize>,
972    ) -> Result<DeviceIoBinding> {
973        self.exec.allocate_device_output_binding(
974            output_name.into(),
975            dtype,
976            physical_shape,
977            logical_shape,
978        )
979    }
980
981    /// Execute once while recording the kernel launches into a device graph.
982    ///
983    /// `NotCapturable` means the mandatory all-kernel audit rejected the run
984    /// before stream capture began, so callers may safely retry eagerly.
985    pub fn try_capture_with_device_bindings(
986        &mut self,
987        inputs: &[(&str, &Tensor)],
988        bindings: &mut [DeviceIoBinding],
989    ) -> Result<DeviceGraphCaptureResult> {
990        self.exec.try_capture_with_device_bindings(inputs, bindings)
991    }
992
993    /// Replay the installed device graph after the caller has refreshed any
994    /// persistent scalar inputs. Returns `true` when the graph is still valid for
995    /// the next step, or `false` when a control-flow branch flip retired it this
996    /// step (the token was produced correctly via eager fallback) and the caller
997    /// should re-warm and re-capture.
998    pub fn replay_device_graph(&mut self, bindings: &mut [DeviceIoBinding]) -> Result<bool> {
999        self.exec.replay_device_graph(bindings)
1000    }
1001
1002    /// Invalidate the installed device graph before reset, rewind, shape change,
1003    /// or binding destruction.
1004    pub fn reset_device_graph(&mut self) -> Result<bool> {
1005        self.exec.reset_device_graph()
1006    }
1007
1008    /// Number of captured device-graph segments installed by the most recent
1009    /// [`Self::try_capture_with_device_bindings`] call.
1010    ///
1011    /// `1` for a whole-subgraph capture; `>= 2` when the CUDA EP claimed the
1012    /// subgraph but split it into segments around non-capturable seam nodes.
1013    pub fn captured_graph_segment_count(&self) -> usize {
1014        self.exec.captured_segment_count()
1015    }
1016
1017    /// Structured, transparent segment boundaries from the most recent capture:
1018    /// one entry per non-capturable seam node the EP ran eagerly between captured
1019    /// segments (with its structural seam kind and `CaptureSupport` decline reason).
1020    /// Empty for a whole-subgraph capture.
1021    pub fn capture_segmentation(&self) -> &[CaptureDecline] {
1022        self.exec.capture_segmentation()
1023    }
1024
1025    /// Read (without clearing) any latching device capture-safety error recorded
1026    /// during graph replay, as a raw violation bitmask (zero when none). Callers
1027    /// poll this at the per-step logits sync to fail before consuming a token
1028    /// produced from an out-of-range captured replay.
1029    pub fn check_device_capture_error(&self) -> Result<u32> {
1030        self.exec.check_device_capture_error()
1031    }
1032
1033    pub fn device_allocation_counts(&self) -> Option<DeviceAllocationCounts> {
1034        self.exec.device_allocation_counts()
1035    }
1036
1037    pub fn device_id(&self) -> onnx_runtime_ir::DeviceId {
1038        self.exec.device_id()
1039    }
1040
1041    /// Report why an explicitly requested accelerator session was assigned to
1042    /// CPU instead. `None` means the requested EP serves the whole graph.
1043    pub fn execution_provider_fallback_report(&self) -> Option<&ExecutionProviderFallbackReport> {
1044        self.exec.execution_provider_fallback_report()
1045    }
1046
1047    /// Input metadata.
1048    pub fn inputs(&self) -> &[IoMeta] {
1049        &self.inputs
1050    }
1051
1052    /// Output metadata.
1053    pub fn outputs(&self) -> &[IoMeta] {
1054        &self.outputs
1055    }
1056
1057    /// Model-level metadata from the source `ModelProto`.
1058    pub fn model_metadata(&self) -> &ModelMetadata {
1059        &self.model_metadata
1060    }
1061
1062    /// Kernel-cache statistics (§11.1); useful to observe warmup/run reuse.
1063    pub fn cache_stats(&self) -> CacheStats {
1064        self.exec.cache_stats()
1065    }
1066
1067    /// Control-flow subgraph build/run statistics. A Loop or Scan body with a
1068    /// stable input-shape signature should build once and run many times.
1069    pub fn control_flow_stats(&self) -> ControlFlowStats {
1070        self.exec.control_flow_stats()
1071    }
1072
1073    /// Pre-compile kernels for common shapes to avoid first-inference latency
1074    /// (§11.3). Phase-1 minimal: the compiled plan's shapes already key the
1075    /// cache, so this repopulates it for the plan; `shapes` are validated to
1076    /// name real inputs.
1077    pub fn warmup(&mut self, shapes: &[WarmupShape]) -> Result<()> {
1078        for ws in shapes {
1079            if !self.inputs.iter().any(|m| m.name == ws.input_name) {
1080                return Err(SessionError::InputNotFound {
1081                    name: ws.input_name.clone(),
1082                });
1083            }
1084        }
1085        self.exec.warmup()
1086    }
1087
1088    /// The EPContext dump configuration parsed from the `ep.context_*` session
1089    /// options (§21.4). Disabled by default.
1090    pub fn ep_context_config(&self) -> &EpContextDumpConfig {
1091        &self.ep_context_config
1092    }
1093
1094    /// The session's (post-optimize) compiled graph.
1095    ///
1096    /// This is the graph the executor runs and the same one
1097    /// [`Self::export_ep_context`] serialises — a caller identifying the
1098    /// [`NodeId`](onnx_runtime_ir::NodeId)s of a compiled partition (the
1099    /// [`CompiledPartition::covered_nodes`]) must read them from here so they
1100    /// reference the exact nodes the exporter will splice out. This is the
1101    /// compiler-integration seam: a real compiling EP inspects this graph to
1102    /// choose the subgraphs it claims.
1103    pub fn graph(&self) -> &onnx_runtime_ir::Graph {
1104        self.exec.graph()
1105    }
1106
1107    /// Export a `com.microsoft::EPContext` context-cache model for this session
1108    /// (§55.4 dump path), driven by the `ep.context_*` session options
1109    /// ([`Self::ep_context_config`]).
1110    ///
1111    /// `orig_path` is the source model path the default output location
1112    /// (`<orig>_ctx.onnx`) is derived from when `ep.context_file_path` is unset.
1113    /// `partitions` are the EP-compiled partitions to serialise — each names the
1114    /// [`ExecutionProvider`](onnx_runtime_ep_api::ExecutionProvider) that
1115    /// compiled it, so the driver pulls the blob + SDK version via
1116    /// [`save_context`](onnx_runtime_ep_api::ExecutionProvider::save_context) and
1117    /// the `source` key via
1118    /// [`context_source_keys`](onnx_runtime_ep_api::ExecutionProvider::context_source_keys)
1119    /// (§55.6 — nothing is hardcoded).
1120    ///
1121    /// When `ep.context_enable` is `false` (the default) this is a **no-op**: no
1122    /// EP `save_context` is called and no files are written; it returns the path
1123    /// it *would* have written to.
1124    ///
1125    /// # Compiler-integration seam
1126    ///
1127    /// The Phase-1 CPU EP has **no compile step**, so no real EP yet yields
1128    /// [`CompiledPartition`]s — `partitions` is therefore supplied by the
1129    /// caller (proven end-to-end with a mock compiling EP in the crate tests).
1130    /// TODO(compiler): when a real compiling EP lands, collect its partitions
1131    /// from the compile/placement stage and call this internally at build time
1132    /// so a session created with `ep.context_enable=1` dumps automatically.
1133    pub fn export_ep_context(
1134        &self,
1135        orig_path: &Path,
1136        partitions: &[CompiledPartition],
1137    ) -> Result<PathBuf> {
1138        let model = EncoderModel::new(self.exec.graph()).with_weights(self.exec.weights().as_ref());
1139        dump_session_ep_context(&model, orig_path, partitions, &self.ep_context_config)
1140    }
1141}
1142
1143/// Load a model. Auto-detects the best available hardware (§20.2).
1144///
1145/// This is the primary entry point — no configuration required.
1146pub fn load(path: impl AsRef<Path>) -> Result<InferenceSession> {
1147    InferenceSession::load(path)
1148}
1149
1150#[cfg(test)]
1151mod device_binding_tests {
1152    use super::*;
1153    #[cfg(feature = "cuda")]
1154    use onnx_runtime_ir::Attribute;
1155    #[cfg(feature = "cuda")]
1156    use onnx_runtime_ir::static_shape;
1157    use onnx_runtime_ir::{Graph, Node, NodeId};
1158
1159    #[test]
1160    fn persistent_binding_aliases_input_output_and_suppresses_materialization() {
1161        let mut graph = Graph::new();
1162        graph.opset_imports.insert("".into(), 13);
1163        let length = graph.intern_symbol("length");
1164        let input = graph.create_named_value("input", DataType::Float32, vec![length.into()]);
1165        graph.add_input(input);
1166        let output = graph.create_named_value("output", DataType::Float32, vec![length.into()]);
1167        graph.insert_node(Node::new(
1168            NodeId(0),
1169            "Relu",
1170            vec![Some(input)],
1171            vec![output],
1172        ));
1173        graph.add_output(output);
1174        let mut session = InferenceSession::from_graph(graph).unwrap();
1175        let mut binding = session
1176            .allocate_device_binding("input", Some("output"), DataType::Float32, vec![4], vec![2])
1177            .unwrap();
1178        let ptr = binding.device_ptr();
1179        let bytes = [-2.0f32, 3.0, -4.0, 5.0]
1180            .into_iter()
1181            .flat_map(f32::to_le_bytes)
1182            .collect::<Vec<_>>();
1183        binding.write_bytes(0, &bytes).unwrap();
1184
1185        let outputs = session
1186            .run_with_device_bindings(&[], std::slice::from_mut(&mut binding))
1187            .unwrap();
1188        assert_eq!(outputs.len(), 1);
1189        assert!(outputs[0].is_none());
1190        assert_eq!(binding.device_ptr(), ptr);
1191        assert_eq!(binding.logical_shape(), &[2]);
1192        let values = binding
1193            .read_bytes()
1194            .unwrap()
1195            .chunks_exact(4)
1196            .map(|bytes| f32::from_le_bytes(bytes.try_into().unwrap()))
1197            .collect::<Vec<_>>();
1198        assert_eq!(values, vec![0.0, 3.0, -4.0, 5.0]);
1199        assert_eq!(
1200            binding.transfer_stats(),
1201            DeviceBindingTransferStats {
1202                host_upload_calls: 1,
1203                host_upload_bytes: 16,
1204                host_download_calls: 1,
1205                host_download_bytes: 16,
1206            }
1207        );
1208    }
1209
1210    #[cfg(feature = "cuda")]
1211    #[test]
1212    fn cuda_graph_replay_uses_persistent_io_without_device_allocations() {
1213        let Ok(mut ep) = onnx_runtime_ep_cuda::CudaExecutionProvider::new(0) else {
1214            eprintln!("skipping session CUDA graph test: CUDA runtime unavailable");
1215            return;
1216        };
1217        onnx_runtime_ep_api::ExecutionProvider::initialize(&mut ep, &Default::default()).unwrap();
1218
1219        let mut graph = Graph::new();
1220        graph.opset_imports.insert("".into(), 13);
1221        let input = graph.create_named_value("input", DataType::Int64, static_shape([1]));
1222        graph.add_input(input);
1223        let output = graph.create_named_value("output", DataType::Float32, static_shape([1]));
1224        let mut cast = Node::new(NodeId(0), "Cast", vec![Some(input)], vec![output]);
1225        cast.attributes
1226            .insert("to".into(), Attribute::Int(DataType::Float32 as i64));
1227        graph.insert_node(cast);
1228        graph.add_output(output);
1229
1230        let mut session = InferenceSession::from_parts(
1231            graph,
1232            std::sync::Arc::new(onnx_runtime_loader::WeightStore::new()),
1233            Path::new("."),
1234            EpContextDumpConfig::default(),
1235            ModelMetadata::default(),
1236            std::sync::Arc::new(ep),
1237        )
1238        .unwrap();
1239        let mut input = session
1240            .allocate_device_binding("input", None::<String>, DataType::Int64, vec![1], vec![1])
1241            .unwrap();
1242        let output = session
1243            .allocate_device_output_binding("output", DataType::Float32, vec![1], vec![1])
1244            .unwrap();
1245        input.write_bytes(0, &7i64.to_le_bytes()).unwrap();
1246        let mut bindings = vec![input, output];
1247        session
1248            .run_with_device_bindings(&[], &mut bindings)
1249            .unwrap();
1250
1251        input_write(&mut bindings[0], 11);
1252        assert!(matches!(
1253            session
1254                .try_capture_with_device_bindings(&[], &mut bindings)
1255                .unwrap(),
1256            DeviceGraphCaptureResult::Captured(_)
1257        ));
1258        assert_eq!(read_bound_f32(&mut bindings[1]), 11.0);
1259
1260        let before = session.device_allocation_counts().unwrap();
1261        input_write(&mut bindings[0], 23);
1262        session.replay_device_graph(&mut bindings).unwrap();
1263        assert_eq!(read_bound_f32(&mut bindings[1]), 23.0);
1264        assert_eq!(session.device_allocation_counts().unwrap(), before);
1265        assert!(session.reset_device_graph().unwrap());
1266
1267        input_write(&mut bindings[0], 31);
1268        assert!(matches!(
1269            session
1270                .try_capture_with_device_bindings(&[], &mut bindings)
1271                .unwrap(),
1272            DeviceGraphCaptureResult::Captured(_)
1273        ));
1274        assert_eq!(read_bound_f32(&mut bindings[1]), 31.0);
1275        input_write(&mut bindings[0], 47);
1276        session.replay_device_graph(&mut bindings).unwrap();
1277        assert_eq!(read_bound_f32(&mut bindings[1]), 47.0);
1278        assert!(session.reset_device_graph().unwrap());
1279    }
1280
1281    #[cfg(feature = "cuda")]
1282    #[test]
1283    fn segmented_cuda_graph_claims_whole_subgraph_around_eager_seam() {
1284        let Ok(mut ep) = onnx_runtime_ep_cuda::CudaExecutionProvider::new(0) else {
1285            eprintln!("skipping segmented session CUDA graph test: CUDA runtime unavailable");
1286            return;
1287        };
1288        onnx_runtime_ep_api::ExecutionProvider::initialize(&mut ep, &Default::default()).unwrap();
1289
1290        // A decoder-like chain with a deliberately non-capturable node in the
1291        // middle: input -> Cast(f32) -> Clip(min/max attrs) -> Cast(i64) -> out.
1292        // Cast is CUDA-graph capture-safe (it skips its trailing sync while the
1293        // stream is capturing); Clip declines capture, so it forces a segment
1294        // boundary while remaining CUDA-placed. Over integer inputs and a wide
1295        // clip the chain round-trips to the identity.
1296        let n = 4usize;
1297        let mut graph = Graph::new();
1298        graph.opset_imports.insert("".into(), 13);
1299        let input = graph.create_named_value("input", DataType::Int64, static_shape([n]));
1300        graph.add_input(input);
1301        let as_float = graph.create_named_value("as_float", DataType::Float32, static_shape([n]));
1302        let mut cast_in = Node::new(NodeId(0), "Cast", vec![Some(input)], vec![as_float]);
1303        cast_in
1304            .attributes
1305            .insert("to".into(), Attribute::Int(DataType::Float32 as i64));
1306        graph.insert_node(cast_in);
1307        let clipped = graph.create_named_value("clipped", DataType::Float32, static_shape([n]));
1308        let mut clip = Node::new(NodeId(1), "Clip", vec![Some(as_float)], vec![clipped]);
1309        clip.attributes
1310            .insert("min".into(), Attribute::Float(-1000.0));
1311        clip.attributes
1312            .insert("max".into(), Attribute::Float(1000.0));
1313        graph.insert_node(clip);
1314        let output = graph.create_named_value("output", DataType::Int64, static_shape([n]));
1315        let mut cast_out = Node::new(NodeId(2), "Cast", vec![Some(clipped)], vec![output]);
1316        cast_out
1317            .attributes
1318            .insert("to".into(), Attribute::Int(DataType::Int64 as i64));
1319        graph.insert_node(cast_out);
1320        graph.add_output(output);
1321
1322        let mut session = InferenceSession::from_parts(
1323            graph,
1324            std::sync::Arc::new(onnx_runtime_loader::WeightStore::new()),
1325            Path::new("."),
1326            EpContextDumpConfig::default(),
1327            ModelMetadata::default(),
1328            std::sync::Arc::new(ep),
1329        )
1330        .unwrap();
1331
1332        let input_binding = session
1333            .allocate_device_binding("input", None::<String>, DataType::Int64, vec![n], vec![n])
1334            .unwrap();
1335        let output_binding = session
1336            .allocate_device_output_binding("output", DataType::Int64, vec![n], vec![n])
1337            .unwrap();
1338        let mut bindings = vec![input_binding, output_binding];
1339
1340        // Warmup / eager reference for input A (also warms the shape-keyed
1341        // kernels the capture pass requires).
1342        let values_a = [-2i64, 3, -4, 5];
1343        write_bound_i64(&mut bindings[0], &values_a);
1344        session
1345            .run_with_device_bindings(&[], &mut bindings)
1346            .unwrap();
1347        let eager_a = read_bound_i64_vec(&mut bindings[1]);
1348        assert_eq!(
1349            eager_a,
1350            values_a.to_vec(),
1351            "Cast∘Clip∘Cast round-trips ints"
1352        );
1353
1354        // Segmented capture: the whole subgraph is still claimed and run on the
1355        // CUDA EP even though Clip is not capturable.
1356        match session
1357            .try_capture_with_device_bindings(&[], &mut bindings)
1358            .unwrap()
1359        {
1360            DeviceGraphCaptureResult::Captured(outputs) => {
1361                assert!(
1362                    outputs.iter().all(Option::is_none),
1363                    "device-bound outputs must not materialize to host"
1364                );
1365            }
1366            DeviceGraphCaptureResult::NotCapturable(report) => {
1367                panic!(
1368                    "expected the CUDA EP to claim the whole subgraph via segmented capture, \
1369                     got a full decline: {report}"
1370                );
1371            }
1372        }
1373        // Whole-subgraph claim, split into two captured segments around one seam.
1374        assert_eq!(
1375            session.captured_graph_segment_count(),
1376            2,
1377            "Clip should split the plan into two captured Cast segments"
1378        );
1379        let seams = session.capture_segmentation();
1380        assert_eq!(seams.len(), 1, "exactly one eager seam node (Clip)");
1381        assert_eq!(seams[0].op_type, "Clip");
1382        // Token-exact: the segmented capture pass matches the eager reference.
1383        assert_eq!(read_bound_i64_vec(&mut bindings[1]), eager_a);
1384
1385        // Segmented replay for a new input B interleaves the two captured Cast
1386        // segment graphs with the eager Clip seam, and stays token-exact.
1387        let values_b = [7i64, -1, 0, -8];
1388        write_bound_i64(&mut bindings[0], &values_b);
1389        session.replay_device_graph(&mut bindings).unwrap();
1390        let replay_b = read_bound_i64_vec(&mut bindings[1]);
1391
1392        // Independent eager reference for input B.
1393        assert!(session.reset_device_graph().unwrap());
1394        write_bound_i64(&mut bindings[0], &values_b);
1395        session
1396            .run_with_device_bindings(&[], &mut bindings)
1397            .unwrap();
1398        let eager_b = read_bound_i64_vec(&mut bindings[1]);
1399        assert_eq!(
1400            replay_b, eager_b,
1401            "segmented replay must be bit-identical to eager execution"
1402        );
1403        assert_eq!(replay_b, values_b.to_vec());
1404    }
1405
1406    #[cfg(feature = "cuda")]
1407    fn write_bound_i64(binding: &mut DeviceIoBinding, values: &[i64]) {
1408        let bytes = values
1409            .iter()
1410            .flat_map(|value| value.to_le_bytes())
1411            .collect::<Vec<_>>();
1412        binding.write_bytes(0, &bytes).unwrap();
1413    }
1414
1415    #[cfg(feature = "cuda")]
1416    fn read_bound_i64_vec(binding: &mut DeviceIoBinding) -> Vec<i64> {
1417        binding
1418            .read_bytes()
1419            .unwrap()
1420            .chunks_exact(8)
1421            .map(|bytes| i64::from_le_bytes(bytes.try_into().unwrap()))
1422            .collect()
1423    }
1424
1425    #[cfg(feature = "cuda")]
1426    fn input_write(binding: &mut DeviceIoBinding, value: i64) {
1427        binding.write_bytes(0, &value.to_le_bytes()).unwrap();
1428    }
1429
1430    #[cfg(feature = "cuda")]
1431    fn read_bound_f32(binding: &mut DeviceIoBinding) -> f32 {
1432        let bytes = binding.read_bytes().unwrap();
1433        f32::from_le_bytes(bytes.try_into().unwrap())
1434    }
1435}
1436
1437#[cfg(test)]
1438mod option_tests {
1439    use super::*;
1440
1441    fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1442        pairs
1443            .iter()
1444            .map(|(k, v)| (k.to_string(), v.to_string()))
1445            .collect()
1446    }
1447
1448    fn level_of(pairs: &[(&str, &str)]) -> Result<OptimizationLevel> {
1449        SessionBuilder::parse_options(&opts(pairs)).map(|(level, _)| level)
1450    }
1451
1452    fn ctx_of(pairs: &[(&str, &str)]) -> Result<EpContextDumpConfig> {
1453        SessionBuilder::parse_options(&opts(pairs)).map(|(_, ctx)| ctx)
1454    }
1455
1456    #[test]
1457    fn optimization_defaults_to_none_when_unset() {
1458        assert_eq!(level_of(&[]).unwrap(), OptimizationLevel::None);
1459    }
1460
1461    #[test]
1462    fn explicit_execution_provider_is_retained_by_builder() {
1463        let builder =
1464            SessionBuilder::new().execution_provider(executor::auto_detect_cpu_ep().unwrap());
1465        assert!(builder.execution_provider.is_some());
1466    }
1467
1468    #[test]
1469    fn optimization_parses_known_values() {
1470        for (v, want) in [
1471            ("none", OptimizationLevel::None),
1472            ("off", OptimizationLevel::None),
1473            ("BASIC", OptimizationLevel::Basic),
1474            ("All", OptimizationLevel::All),
1475        ] {
1476            assert_eq!(
1477                level_of(&[("optimization", v)]).unwrap(),
1478                want,
1479                "value {v:?}"
1480            );
1481        }
1482    }
1483
1484    #[test]
1485    fn unknown_option_key_is_rejected() {
1486        let err = level_of(&[("optimisation", "all")]).unwrap_err();
1487        assert!(matches!(err, SessionError::UnknownOption { key } if key == "optimisation"));
1488    }
1489
1490    #[test]
1491    fn invalid_optimization_value_is_rejected() {
1492        let err = level_of(&[("optimization", "aggressive")]).unwrap_err();
1493        assert!(matches!(
1494            err,
1495            SessionError::InvalidOption { key, value, .. } if key == "optimization" && value == "aggressive"
1496        ));
1497    }
1498
1499    #[test]
1500    fn none_level_selects_no_passes() {
1501        assert!(OptimizationLevel::None.passes().is_empty());
1502        assert_eq!(OptimizationLevel::Basic.passes().len(), 2);
1503        assert_eq!(OptimizationLevel::All.passes().len(), 3);
1504    }
1505
1506    // ── EPContext dump options (§21.4 / §55.5) ────────────────────────────────
1507
1508    #[test]
1509    fn ep_context_defaults_to_disabled() {
1510        let ctx = ctx_of(&[]).unwrap();
1511        assert_eq!(ctx, EpContextDumpConfig::default());
1512        assert!(!ctx.enable);
1513        assert_eq!(ctx.file_path, None);
1514        assert_eq!(ctx.embed_mode, 1);
1515    }
1516
1517    #[test]
1518    fn ep_context_enable_parses_bool_forms() {
1519        for (v, want) in [
1520            ("1", true),
1521            ("0", false),
1522            ("true", true),
1523            ("TRUE", true),
1524            ("false", false),
1525            ("False", false),
1526        ] {
1527            let ctx = ctx_of(&[("ep.context_enable", v)]).unwrap();
1528            assert_eq!(ctx.enable, want, "value {v:?}");
1529        }
1530    }
1531
1532    #[test]
1533    fn ep_context_enable_rejects_garbage() {
1534        let err = ctx_of(&[("ep.context_enable", "yes")]).unwrap_err();
1535        assert!(matches!(
1536            err,
1537            SessionError::InvalidOption { key, value, .. }
1538                if key == "ep.context_enable" && value == "yes"
1539        ));
1540    }
1541
1542    #[test]
1543    fn ep_context_file_path_parses_and_empty_clears() {
1544        let ctx = ctx_of(&[("ep.context_file_path", "/out/net_ctx.onnx")]).unwrap();
1545        assert_eq!(ctx.file_path, Some(PathBuf::from("/out/net_ctx.onnx")));
1546
1547        // Empty value falls back to the `<orig>_ctx.onnx` default (None).
1548        let ctx = ctx_of(&[("ep.context_file_path", "")]).unwrap();
1549        assert_eq!(ctx.file_path, None);
1550    }
1551
1552    #[test]
1553    fn ep_context_embed_mode_parses_and_rejects() {
1554        assert_eq!(
1555            ctx_of(&[("ep.context_embed_mode", "0")])
1556                .unwrap()
1557                .embed_mode,
1558            0
1559        );
1560        assert_eq!(
1561            ctx_of(&[("ep.context_embed_mode", "1")])
1562                .unwrap()
1563                .embed_mode,
1564            1
1565        );
1566
1567        let err = ctx_of(&[("ep.context_embed_mode", "2")]).unwrap_err();
1568        assert!(matches!(
1569            err,
1570            SessionError::InvalidOption { key, value, expected }
1571                if key == "ep.context_embed_mode" && value == "2" && expected == "0, 1"
1572        ));
1573    }
1574
1575    #[test]
1576    fn ep_context_options_combine_with_optimization() {
1577        let (level, ctx) = SessionBuilder::parse_options(&opts(&[
1578            ("optimization", "all"),
1579            ("ep.context_enable", "1"),
1580            ("ep.context_file_path", "/tmp/out_ctx.onnx"),
1581            ("ep.context_embed_mode", "0"),
1582        ]))
1583        .unwrap();
1584        assert_eq!(level, OptimizationLevel::All);
1585        assert!(ctx.enable);
1586        assert_eq!(ctx.file_path, Some(PathBuf::from("/tmp/out_ctx.onnx")));
1587        assert_eq!(ctx.embed_mode, 0);
1588    }
1589}