Skip to main content

onnx_runtime_loader/
epcontext.rs

1//! `com.microsoft::EPContext` node support — the load path (§55.3).
2//!
3//! An `EPContext` node is the **on-disk / interchange form** of a compiled-EP
4//! context (§55.1): an ordinary ONNX contrib node (`op_type = "EPContext"`,
5//! `domain = "com.microsoft"`) that either embeds a pre-compiled vendor blob
6//! inline or references it in an external file. This module provides:
7//!
8//! * [`EpContextNode`] — a typed *view* over an ordinary IR [`Node`], parsing
9//!   the §55.2 attributes. No new IR node kind is introduced.
10//! * [`ep_context_nodes`] — enumerate the `EPContext` nodes in a [`Graph`].
11//! * [`resolve_ep_context`] — turn the `ep_cache_context` attribute into an
12//!   [`EpContextBlob`] (inline bytes, or a read-only `mmap` of an external file
13//!   resolved relative to the model directory, §55.3).
14//!
15//! The loader **never interprets** the blob bytes — they are opaque vendor
16//! payload handed to whichever EP claims the node's `source` key (§55.6),
17//! which is out of scope here (owned by the session + ep-api).
18
19use std::fs::File;
20use std::path::{Path, PathBuf};
21
22use memmap2::Mmap;
23use onnx_runtime_ir::{Attribute, Graph, Node, NodeId, ValueId};
24
25use crate::{pathsafe::guarded_join, LoaderError};
26
27/// The op type of an EPContext node. Shared with the §55.4 writer so the dump
28/// path never re-invents the literal.
29pub(crate) const EP_CONTEXT_OP: &str = "EPContext";
30/// The (single, non-aliased) domain EPContext nodes live in. The shape-inference
31/// registry only normalises the default ONNX domain (`ai.onnx` ⇄ `""`); there is
32/// no `com.microsoft` alias to normalise, so an exact match is correct (§55.3).
33/// Shared with the §55.4 writer (opset import + node domain).
34pub(crate) const MS_DOMAIN: &str = "com.microsoft";
35
36/// Attribute names (§55.2). Shared between the load path (this module) and the
37/// §55.4 dump path (`crate::writer`) so both sides key on identical strings.
38pub(crate) mod attr {
39    pub const MAIN_CONTEXT: &str = "main_context";
40    pub const EP_CACHE_CONTEXT: &str = "ep_cache_context";
41    pub const EMBED_MODE: &str = "embed_mode";
42    pub const EP_SDK_VERSION: &str = "ep_sdk_version";
43    pub const SOURCE: &str = "source";
44    pub const PARTITION_NAME: &str = "partition_name";
45}
46
47/// Whether the payload lives inline in the node or in an external file (§55.2).
48///
49/// Mirrors the `embed_mode` attribute: `1` (or absent, the §55.2 default) means
50/// [`Embedded`](EmbedMode::Embedded); `0` means [`ExternalFile`](EmbedMode::ExternalFile).
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum EmbedMode {
53    /// `embed_mode = 1`: `ep_cache_context` holds the compiled blob inline.
54    Embedded,
55    /// `embed_mode = 0`: `ep_cache_context` holds an external file path
56    /// (relative to the ONNX model file).
57    ExternalFile,
58}
59
60impl EmbedMode {
61    /// Decode the `embed_mode` attribute integer. Only `0` selects the external
62    /// file; every other value (including the §55.2 default `1`) is treated as
63    /// [`Embedded`] — the fail-closed choice, since it never touches the
64    /// filesystem.
65    fn from_attr_int(v: i64) -> Self {
66        match v {
67            0 => EmbedMode::ExternalFile,
68            _ => EmbedMode::Embedded,
69        }
70    }
71}
72
73/// Where the compiled blob physically lives after load-time resolution (§55.3).
74///
75/// The loader treats the bytes as opaque; it never parses them.
76#[derive(Debug)]
77pub enum EpContextBlob {
78    /// `embed_mode = 1`: bytes owned inline (copied out of `ep_cache_context`).
79    Embedded(Vec<u8>),
80    /// `embed_mode = 0`: a read-only `mmap` of an external file resolved
81    /// *relative to the model directory*. Never eagerly copied into the graph.
82    External {
83        /// The resolved absolute path to the external context file.
84        path: PathBuf,
85        /// Read-only memory map of the file's bytes.
86        map: Mmap,
87    },
88}
89
90impl EpContextBlob {
91    /// Borrow the raw blob bytes regardless of where they live.
92    pub fn bytes(&self) -> &[u8] {
93        match self {
94            EpContextBlob::Embedded(b) => b,
95            EpContextBlob::External { map, .. } => &map[..],
96        }
97    }
98}
99
100/// A typed view over a `com.microsoft::EPContext` node in the [`Graph`] IR
101/// (§55.3). Backed by an ordinary [`Node`] plus its attributes — no separate IR
102/// node kind. Constructed via [`EpContextNode::from_node`] or enumerated with
103/// [`ep_context_nodes`].
104///
105/// Variadic boundary tensors are read straight from the underlying node via
106/// [`inputs`](EpContextNode::inputs) / [`outputs`](EpContextNode::outputs); no
107/// fixed arity is assumed.
108#[derive(Debug)]
109pub struct EpContextNode<'g> {
110    /// The id of the backing IR node.
111    pub node: NodeId,
112    /// `source` attribute — the EP dispatch key (§55.6). `None` if absent.
113    pub source: Option<&'g str>,
114    /// `main_context != 0` (§55.2 default `true` when the attribute is absent).
115    pub main_context: bool,
116    /// Embedded vs. external payload (§55.2 default [`EmbedMode::Embedded`]).
117    pub embed_mode: EmbedMode,
118    /// `ep_sdk_version` attribute — SDK/toolchain version (diagnostics).
119    pub sdk_version: Option<&'g str>,
120    /// `partition_name` attribute — the ORT-partitioned graph name.
121    pub partition_name: Option<&'g str>,
122    /// The backing node, used to reach the variadic i/o and the raw
123    /// `ep_cache_context` payload.
124    inner: &'g Node,
125}
126
127impl<'g> EpContextNode<'g> {
128    /// Build the typed view if `node` is a `com.microsoft::EPContext` node,
129    /// otherwise `None`. Attribute parsing is defensive: missing or wrong-typed
130    /// attributes fall back to the §55.2 defaults rather than erroring.
131    pub fn from_node(node: &'g Node) -> Option<Self> {
132        if !is_ep_context_op(&node.op_type, &node.domain) {
133            return None;
134        }
135        let a = &node.attributes;
136        let main_context = a
137            .get(attr::MAIN_CONTEXT)
138            .and_then(Attribute::as_int)
139            .map(|v| v != 0)
140            .unwrap_or(true);
141        let embed_mode = a
142            .get(attr::EMBED_MODE)
143            .and_then(Attribute::as_int)
144            .map(EmbedMode::from_attr_int)
145            .unwrap_or(EmbedMode::Embedded);
146        Some(EpContextNode {
147            node: node.id,
148            source: str_attr(node, attr::SOURCE),
149            main_context,
150            embed_mode,
151            sdk_version: str_attr(node, attr::EP_SDK_VERSION),
152            partition_name: str_attr(node, attr::PARTITION_NAME),
153            inner: node,
154        })
155    }
156
157    /// The node's variadic input value slots (partition boundary inputs, in
158    /// order). `None` slots preserve positional arity for skipped optionals.
159    pub fn inputs(&self) -> &'g [Option<ValueId>] {
160        &self.inner.inputs
161    }
162
163    /// The node's variadic output values (partition boundary outputs, in order).
164    pub fn outputs(&self) -> &'g [ValueId] {
165        &self.inner.outputs
166    }
167
168    /// The backing [`Node`].
169    pub fn inner(&self) -> &'g Node {
170        self.inner
171    }
172
173    /// Raw `ep_cache_context` bytes, if present. STRING attributes are stored in
174    /// the IR as raw bytes (see `Attribute::String`), so a binary payload is
175    /// never mangled by UTF-8 decoding. A `UINT8`/opaque tensor is accepted as a
176    /// fallback (e.g. a hand-built graph) (§55.3).
177    fn ep_cache_context_bytes(&self) -> Option<&'g [u8]> {
178        match self.inner.attributes.get(attr::EP_CACHE_CONTEXT)? {
179            Attribute::String(s) => Some(s),
180            Attribute::Tensor(t) => Some(&t.data),
181            _ => None,
182        }
183    }
184}
185
186/// Whether `(op_type, domain)` identifies a `com.microsoft::EPContext` node.
187pub fn is_ep_context_op(op_type: &str, domain: &str) -> bool {
188    op_type == EP_CONTEXT_OP && domain == MS_DOMAIN
189}
190
191/// Read a non-empty string attribute, returning `None` when absent, empty, or
192/// not a string.
193fn str_attr<'g>(node: &'g Node, name: &str) -> Option<&'g str> {
194    node.attributes
195        .get(name)
196        .and_then(Attribute::as_str)
197        .filter(|s| !s.is_empty())
198}
199
200/// Enumerate the `com.microsoft::EPContext` nodes in `graph` as typed views
201/// (§55.3, recognition helper). The session dispatches each one on its `source`
202/// key via the `EpContextRegistry` (§55.6, owned by ep-api/session).
203pub fn ep_context_nodes(graph: &Graph) -> impl Iterator<Item = EpContextNode<'_>> {
204    graph.nodes.values().filter_map(EpContextNode::from_node)
205}
206
207/// The [`NodeId`]s of the `EPContext` nodes in `graph`, in node-arena order.
208pub fn ep_context_node_ids(graph: &Graph) -> Vec<NodeId> {
209    ep_context_nodes(graph).map(|n| n.node).collect()
210}
211
212/// Resolve the payload for one `EPContext` node (§55.3).
213///
214/// * `embed_mode = 1` → copy the inline `ep_cache_context` bytes into
215///   [`EpContextBlob::Embedded`].
216/// * `embed_mode = 0` → treat `ep_cache_context` as a path **relative to the
217///   model directory** (identical policy to external-weight resolution, §19.2),
218///   guard it against traversal/absolute escapes, then open + `mmap` it
219///   read-only into [`EpContextBlob::External`]. The bytes are never eagerly
220///   copied into the graph.
221pub fn resolve_ep_context(
222    model_dir: &Path,
223    n: &EpContextNode,
224) -> Result<EpContextBlob, LoaderError> {
225    let raw = n.ep_cache_context_bytes().ok_or_else(|| {
226        LoaderError::EpContext(format!(
227            "EPContext node {:?} is missing the 'ep_cache_context' attribute",
228            n.node
229        ))
230    })?;
231
232    match n.embed_mode {
233        EmbedMode::Embedded => Ok(EpContextBlob::Embedded(raw.to_vec())),
234        EmbedMode::ExternalFile => {
235            let rel = std::str::from_utf8(raw).map_err(|_| {
236                LoaderError::EpContext(format!(
237                    "EPContext node {:?}: external 'ep_cache_context' path is not valid UTF-8",
238                    n.node
239                ))
240            })?;
241            let path = resolve_external_path(model_dir, rel)?;
242            let file = File::open(&path).map_err(|_| LoaderError::ExternalDataNotFound {
243                path: path.clone(),
244            })?;
245            // SAFETY: identical idiom to `weights.rs`'s external-data mmap — the
246            // `File` is held open for the duration of the map and the bytes are
247            // only ever read immutably (opaque vendor blob). This is the same,
248            // and only, `unsafe` pattern the loader already relies on.
249            let map = unsafe { Mmap::map(&file) }.map_err(|e| LoaderError::Mmap(e.to_string()))?;
250            Ok(EpContextBlob::External { path, map })
251        }
252    }
253}
254
255/// Join a relative external-context path onto `model_dir`, rejecting anything
256/// that escapes the model directory (§55.3 path-safety).
257///
258/// Like external-weight resolution (`weights.rs` §19.2), this rejects absolute
259/// paths, filesystem roots/prefixes, and any `..` component before joining the
260/// stored location to the model directory.
261fn resolve_external_path(model_dir: &Path, rel: &str) -> Result<PathBuf, LoaderError> {
262    guarded_join(model_dir, rel).map_err(|reason| LoaderError::EpContextPath {
263        path: rel.to_string(),
264        reason,
265    })
266}