Skip to main content

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(&registry, &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(&registry, &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}