onnx_runtime_session/epcontext.rs
1//! Session-side `com.microsoft::EPContext` **consume (load) path** (§55.3 /
2//! §55.6 / §55.7).
3//!
4//! This module owns the part of the EPContext contract the session is
5//! responsible for (§55.7): **bypassing placement** for pre-compiled EPContext
6//! nodes and **driving `main_context=1/0` resolution + dedup**. It bridges the
7//! loader's on-disk view (`EpContextNode` / `EpContextBlob`) to the ep-api
8//! runtime form (`EpContext`) and hands each node to its owning EP via
9//! [`ExecutionProvider::load_context`].
10//!
11//! ## Dispatch is a pure source-key lookup (hard rule, §55.6)
12//!
13//! EP selection is **always** by the node's `source` attribute, resolved through
14//! the [`EpContextRegistry`] the session builds from its registered EPs
15//! ([`build_ep_context_registry`]). There are **no hardcoded EP/vendor names**
16//! here — an EP participates iff it declares `source` keys via
17//! [`ExecutionProvider::context_source_keys`]. A node whose `source` matches no
18//! registered EP surfaces a clear [`EpError::NoEpForContext`] rather than a
19//! guess (the model needs an EP that is not loaded).
20//!
21//! ## `main_context` primary / reference resolution (§55.3)
22//!
23//! Some EPs pack multiple compiled graphs into one primary context binary.
24//! Nodes with `main_context=1` **own** the payload; nodes with `main_context=0`
25//! **reference** a sibling primary's already-loaded context, matched by
26//! (`source`, `partition_name`). The session therefore:
27//!
28//! 1. loads every `main_context=1` blob **first**, deduplicating identical
29//! `ep_cache_context` payloads so the same bytes are never handed to
30//! `load_context` twice, then
31//! 2. resolves each `main_context=0` node against an already-loaded primary —
32//! **no second blob load**. A reference with no matching primary is a clear
33//! error rather than a silent skip.
34
35use std::collections::{HashMap, HashSet};
36use std::path::{Path, PathBuf};
37
38use onnx_runtime_ep_api::{EpContext, EpError, EpId, ExecutionProvider, build_ep_context_registry};
39use onnx_runtime_ir::{Graph, NodeId};
40use onnx_runtime_loader::{
41 EpContextDumpConfig, EpContextNode, EpContextPartition, Model, dump_ep_context,
42 ep_context_nodes, resolve_ep_context,
43};
44
45use crate::error::{Result, SessionError};
46
47/// Outcome of the EPContext consume pass over a graph (§55.3).
48///
49/// [`handled`](Self::handled) lists the EPContext nodes that bypassed placement
50/// and were restored (or resolved as a reference) through their owning EP. The
51/// executor skips exactly these nodes — they are pre-compiled and must never be
52/// run as ordinary kernels.
53#[derive(Clone, Debug, Default)]
54pub struct EpContextPlacement {
55 /// EPContext node ids that were dispatched to an EP (bypassing placement).
56 pub handled: Vec<NodeId>,
57}
58
59impl EpContextPlacement {
60 /// Whether the graph contained (and this pass handled) any EPContext nodes.
61 pub fn is_empty(&self) -> bool {
62 self.handled.is_empty()
63 }
64}
65
66/// Identity of a loaded primary context, used to resolve `main_context=0`
67/// references (§55.3). Owned `String`s so the map outlives the borrowed graph.
68type PrimaryKey = (Option<String>, Option<String>); // (source, partition_name)
69
70/// Consume every `com.microsoft::EPContext` node in `graph` (§55.3).
71///
72/// Builds the `source`-keyed [`EpContextRegistry`] from `eps`
73/// ([`build_ep_context_registry`] — propagating
74/// [`EpError::DuplicateContextSource`] if two EPs claim one key), then for each
75/// EPContext node claims the owning EP by its `source` attribute, maps the
76/// loader blob + node attributes into the runtime [`EpContext`], and calls
77/// [`ExecutionProvider::load_context`]. `main_context=1` primaries load first
78/// (deduplicating identical payloads); `main_context=0` references resolve
79/// against an already-loaded primary by (`source`, `partition_name`) with no
80/// second blob load.
81///
82/// `model_dir` is the directory of the ONNX model file, needed to resolve
83/// `embed_mode=0` external blob paths (identical policy to external weights,
84/// §19.2). Returns the set of handled node ids so the executor can bypass them.
85///
86/// # Errors
87///
88/// * [`EpError::DuplicateContextSource`] — two EPs declare the same `source` key.
89/// * [`EpError::NoEpForContext`] — a node's `source` matches no registered EP.
90/// * [`SessionError::DanglingEpContext`] — a `main_context=0` reference has no
91/// matching primary.
92/// * loader errors from [`resolve_ep_context`] (missing payload, bad external
93/// path), and any EP error from [`ExecutionProvider::load_context`].
94pub fn load_ep_context_nodes(
95 graph: &Graph,
96 model_dir: &Path,
97 eps: &[(EpId, &dyn ExecutionProvider)],
98) -> Result<EpContextPlacement> {
99 let nodes: Vec<EpContextNode<'_>> = ep_context_nodes(graph).collect();
100 if nodes.is_empty() {
101 return Ok(EpContextPlacement::default());
102 }
103
104 // Model-agnostic dispatch table: keys come only from each EP's
105 // `context_source_keys()`; no vendor name is ever hardcoded (§55.6).
106 let registry = build_ep_context_registry(eps.iter().copied())?;
107 let ep_by_id: HashMap<EpId, &dyn ExecutionProvider> = eps.iter().copied().collect();
108
109 let mut handled = Vec::with_capacity(nodes.len());
110 // Primaries loaded so far, for reference resolution (§55.3).
111 let mut primaries: HashSet<PrimaryKey> = HashSet::new();
112 // Payloads already handed to `load_context`, to avoid loading identical
113 // bytes twice (§55.3 dedup). Keyed by (source, bytes).
114 let mut loaded_payloads: HashSet<(Option<String>, Vec<u8>)> = HashSet::new();
115
116 // Phase 1 — primaries (`main_context=1`) load their blobs first.
117 for node in nodes.iter().filter(|n| n.main_context) {
118 let ep = claim_ep(®istry, &ep_by_id, node)?;
119
120 let blob = resolve_ep_context(model_dir, node)?;
121 let dedup_key = (node.source.map(str::to_owned), blob.bytes().to_vec());
122 if loaded_payloads.insert(dedup_key) {
123 // First time we see these exact bytes for this source → restore.
124 let ctx = EpContext {
125 ep_name: ep.name().to_string(),
126 // `ep_sdk_version` attr → runtime `ep_version` (diagnostics).
127 ep_version: node.sdk_version.unwrap_or_default().to_string(),
128 // Opaque `ep_cache_context` payload.
129 data: blob.bytes().to_vec(),
130 // This node's boundary == the partition it replaces.
131 covered_nodes: vec![node.node],
132 // Filled/validated by the EP.
133 device_fingerprint: String::new(),
134 };
135 ep.load_context(&ctx)?;
136 }
137
138 primaries.insert((
139 node.source.map(str::to_owned),
140 node.partition_name.map(str::to_owned),
141 ));
142 handled.push(node.node);
143 }
144
145 // Phase 2 — references (`main_context=0`) resolve against a loaded primary;
146 // no second blob load.
147 for node in nodes.iter().filter(|n| !n.main_context) {
148 // Still require an EP for this source (model-agnostic; no guessing).
149 claim_ep(®istry, &ep_by_id, node)?;
150
151 let key = (
152 node.source.map(str::to_owned),
153 node.partition_name.map(str::to_owned),
154 );
155 if !primaries.contains(&key) {
156 return Err(SessionError::DanglingEpContext {
157 source_key: node.source.map(str::to_owned),
158 partition_name: node.partition_name.map(str::to_owned),
159 });
160 }
161 handled.push(node.node);
162 }
163
164 Ok(EpContextPlacement { handled })
165}
166
167/// Resolve the EP that owns `node` by its `source` key (§55.6). An unclaimed
168/// node is a clear [`EpError::NoEpForContext`] naming the missing source, never
169/// a guess.
170fn claim_ep<'e>(
171 registry: &onnx_runtime_ep_api::EpContextRegistry,
172 ep_by_id: &HashMap<EpId, &'e dyn ExecutionProvider>,
173 node: &EpContextNode<'_>,
174) -> Result<&'e dyn ExecutionProvider> {
175 let ep_id = registry
176 .claim(node.source)
177 .ok_or_else(|| EpError::NoEpForContext {
178 source_key: node.source.map(str::to_owned),
179 })?;
180 // The registry only ever holds ids from `eps`, so this lookup is total; a
181 // miss would be an internal inconsistency.
182 ep_by_id.get(&ep_id).copied().ok_or_else(|| {
183 SessionError::Internal(format!(
184 "EPContext registry returned unknown Ep id {ep_id:?} for source {:?}",
185 node.source
186 ))
187 })
188}
189
190// ── EPContext DUMP / WRITER path (§55.4) ──────────────────────────────────────
191
192/// One compiled partition the session hands to the EPContext writer (§55.4).
193///
194/// The session owns compilation and partition boundaries; it names the owning
195/// EP and the graph nodes that EP compiled. The driver ([`dump_session_ep_context`])
196/// pulls the compiled blob + SDK version from the EP via
197/// [`ExecutionProvider::save_context`] and the `source` key from
198/// [`ExecutionProvider::context_source_keys`] — nothing is hardcoded (§55.6).
199pub struct CompiledPartition<'a> {
200 /// The EP that compiled this partition (and owns its context).
201 pub ep: &'a dyn ExecutionProvider,
202 /// The ORT-partition name emitted as the node's `partition_name` attribute.
203 pub partition_name: String,
204 /// The graph nodes this partition covers (replaced by one EPContext node).
205 pub covered_nodes: Vec<NodeId>,
206}
207
208/// Drive the §55.4 dump path: serialise `model` to a `*_ctx.onnx` context-cache
209/// model, replacing each compiled `partition`'s subgraph with a single
210/// `com.microsoft::EPContext` node.
211///
212/// For each partition the driver calls [`ExecutionProvider::save_context`] to
213/// obtain the runtime context (blob + `ep_version`) and takes the EP's first
214/// declared [`ExecutionProvider::context_source_keys`] as the node's `source`
215/// key (§55.6 — model-agnostic; no vendor name is hardcoded here). It then hands
216/// the neutral partition views to the loader-owned writer
217/// ([`onnx_runtime_loader::dump_ep_context`]). Returns the written model path.
218///
219/// If [`EpContextDumpConfig::enable`] is `false` this is a no-op: it does **not**
220/// call any EP's `save_context`, writes no files, and returns the path it *would*
221/// have written to — so a disabled config has no side effects by construction.
222///
223/// # Errors
224///
225/// * [`EpError::UnsupportedContext`] — an EP with no compile step (its
226/// `save_context` default).
227/// * [`SessionError::Internal`] — an EP that participates in compilation but
228/// declares no `source` key (it cannot be dispatched on reload).
229/// * loader errors from the writer (I/O, encoding).
230pub fn dump_session_ep_context(
231 model: &Model,
232 orig_path: &Path,
233 partitions: &[CompiledPartition],
234 config: &EpContextDumpConfig,
235) -> Result<PathBuf> {
236 // Honour `ep.context_enable` before any side effect (no `save_context`, no
237 // I/O): a disabled config is a no-op. Delegate the would-be path to the
238 // loader so both drivers agree on the default `<stem>_ctx.onnx` location.
239 if !config.enable {
240 return Ok(dump_ep_context(model, orig_path, &[], config)?);
241 }
242
243 // Materialise each EP's runtime context first so the loader partition views
244 // can borrow its bytes / version for the duration of the dump.
245 struct Owned {
246 source: String,
247 ctx: EpContext,
248 partition_name: String,
249 covered_nodes: Vec<NodeId>,
250 }
251 let mut owned = Vec::with_capacity(partitions.len());
252 for part in partitions {
253 let ctx = part.ep.save_context()?;
254 let source = part.ep.context_source_keys().into_iter().next().ok_or_else(|| {
255 SessionError::Internal(format!(
256 "EP {:?} produced a context but declares no `source` key — the \
257 EPContext node could not be dispatched on reload (§55.6)",
258 part.ep.name()
259 ))
260 })?;
261 owned.push(Owned {
262 source,
263 ctx,
264 partition_name: part.partition_name.clone(),
265 covered_nodes: part.covered_nodes.clone(),
266 });
267 }
268
269 let loader_parts: Vec<EpContextPartition<'_>> = owned
270 .iter()
271 .map(|o| EpContextPartition {
272 source: &o.source,
273 ep_sdk_version: &o.ctx.ep_version,
274 partition_name: &o.partition_name,
275 // The session emits primaries; multi-graph `main_context=0`
276 // referencing is an EP-packing concern handled on the load side.
277 main_context: true,
278 blob: &o.ctx.data,
279 covered_nodes: &o.covered_nodes,
280 })
281 .collect();
282
283 Ok(dump_ep_context(model, orig_path, &loader_parts, config)?)
284}