Skip to main content

onnx_runtime_ep_api/
epcontext.rs

1//! Runtime `EpContext` form and the `source`-keyed [`EpContextRegistry`]
2//! (`docs/ORT2.md` §55.1 / §55.6).
3//!
4//! ## Two forms of one thing
5//!
6//! An ORT `com.microsoft::EPContext` node is the **on-disk / interchange** form
7//! of a compiled-EP context (§55.1). The loader (`onnx-runtime-loader`) owns the
8//! typed *view* over that node (`EpContextNode`) and resolves its blob
9//! (`EpContextBlob`). This module owns the **runtime** form: the in-memory
10//! [`EpContext`] an EP produces via [`ExecutionProvider::save_context`] and
11//! consumes via [`ExecutionProvider::load_context`], plus the registry that maps
12//! a node's `source` attribute → the [`EpId`] that owns it.
13//!
14//! ## Model-agnostic dispatch (hard rule, §55.6)
15//!
16//! EP selection for an `EPContext` node is **always** by the node's `source`
17//! attribute, resolved through [`EpContextRegistry`] — **never** by a hardcoded
18//! EP/vendor name. Each EP declares the `source` key(s) it accepts (from its own
19//! config/data, not code) via [`ExecutionProvider::context_source_keys`]; the
20//! registry maps key → EP. Adding a new compiled EP requires **no change** to
21//! loader/session dispatch code.
22
23use std::collections::HashMap;
24
25use crate::error::{EpError, Result};
26use crate::provider::{EpId, ExecutionProvider};
27
28/// The **runtime form** of a compiled-EP context (`docs/ORT2.md` §4 / §55.1).
29///
30/// This is the in-memory representation an EP produces from a freshly compiled
31/// subgraph ([`ExecutionProvider::save_context`]) or restores at load
32/// ([`ExecutionProvider::load_context`]), skipping the expensive
33/// convert+compile step. It is distinct from the loader's on-disk
34/// `EpContextNode`/`EpContextBlob` (which is the serialized, in-graph,
35/// tool-portable interchange form); the session maps between the two (§55.3).
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct EpContext {
38    /// Name of the EP that produced (and must consume) this context.
39    pub ep_name: String,
40    /// SDK/toolchain version that generated the context (maps to the node's
41    /// `ep_sdk_version` attribute — used for invalidation / diagnostics).
42    pub ep_version: String,
43    /// Opaque compiled vendor blob (maps to the node's `ep_cache_context`).
44    pub data: Vec<u8>,
45    /// Graph nodes this context covers (the partition boundary it replaces).
46    pub covered_nodes: Vec<onnx_runtime_ir::NodeId>,
47    /// Device/target fingerprint the blob was compiled for; filled and
48    /// validated by the owning EP to reject a mismatched load.
49    pub device_fingerprint: String,
50}
51
52impl EpContext {
53    /// Construct a runtime context from its parts.
54    pub fn new(
55        ep_name: impl Into<String>,
56        ep_version: impl Into<String>,
57        data: Vec<u8>,
58        covered_nodes: Vec<onnx_runtime_ir::NodeId>,
59        device_fingerprint: impl Into<String>,
60    ) -> Self {
61        Self {
62            ep_name: ep_name.into(),
63            ep_version: ep_version.into(),
64            data,
65            covered_nodes,
66            device_fingerprint: device_fingerprint.into(),
67        }
68    }
69}
70
71/// Registry mapping an `EPContext` node's `source` key → the [`EpId`] that owns
72/// it (`docs/ORT2.md` §55.6).
73///
74/// EPs register the `source` key(s) they accept; dispatch ([`claim`]) is a pure
75/// lookup. There are **no hardcoded vendor names** here — the keys come entirely
76/// from each EP's [`ExecutionProvider::context_source_keys`].
77///
78/// ## Duplicate keys are rejected (reject-duplicate-key)
79///
80/// [`register`] returns [`EpError::DuplicateContextSource`] if a `source` key is
81/// already claimed by another EP, rather than silently overwriting
82/// (last-writer-wins) it. Rationale: two EPs claiming the same `source` is a
83/// **configuration error** (the model would dispatch ambiguously), and silently
84/// dropping one binding would make which EP restores a context depend on
85/// registration order — a non-deterministic, hard-to-debug failure at load. A
86/// registrant re-declaring the *same* `(key, ep)` binding is idempotent and
87/// accepted (it is not a conflict).
88///
89/// [`register`]: EpContextRegistry::register
90/// [`claim`]: EpContextRegistry::claim
91#[derive(Clone, Debug, Default)]
92pub struct EpContextRegistry {
93    by_source: HashMap<String, EpId>,
94}
95
96impl EpContextRegistry {
97    /// An empty registry.
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Declare that `ep` accepts each key in `source_keys` for `EPContext`
103    /// dispatch. Keys come from the EP's own config/data (§55.6), never
104    /// hardcoded here.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`EpError::DuplicateContextSource`] if any key is already
109    /// registered to a **different** EP (reject-duplicate-key). Re-registering
110    /// the same `(key, ep)` binding is idempotent. On error, keys processed
111    /// before the conflict remain registered (the caller should treat a
112    /// duplicate as a fatal configuration error).
113    pub fn register(&mut self, ep: EpId, source_keys: &[String]) -> Result<()> {
114        for key in source_keys {
115            match self.by_source.get(key) {
116                Some(&existing) if existing == ep => {} // idempotent re-declare
117                Some(&existing) => {
118                    return Err(EpError::DuplicateContextSource {
119                        source_key: key.clone(),
120                        existing,
121                        new: ep,
122                    });
123                }
124                None => {
125                    self.by_source.insert(key.clone(), ep);
126                }
127            }
128        }
129        Ok(())
130    }
131
132    /// Look up the EP that owns a node's `source` attribute (§55.6).
133    ///
134    /// A pure lookup: `None` source (attribute absent) or a `source` matching no
135    /// registered EP both yield `None` — the node is **unclaimed**. The session
136    /// turns an unclaimed node into a clear [`EpError::NoEpForContext`] rather
137    /// than guessing.
138    pub fn claim(&self, source: Option<&str>) -> Option<EpId> {
139        self.by_source.get(source?).copied()
140    }
141
142    /// Number of registered `source` keys.
143    pub fn len(&self) -> usize {
144        self.by_source.len()
145    }
146
147    /// Whether no `source` keys are registered.
148    pub fn is_empty(&self) -> bool {
149        self.by_source.is_empty()
150    }
151}
152
153/// Build an [`EpContextRegistry`] from a set of registered EPs (§55.6).
154///
155/// A **pure function over the EP set**: it iterates the `(EpId, &dyn
156/// ExecutionProvider)` pairs, asks each EP for its
157/// [`ExecutionProvider::context_source_keys`], and populates the registry. This
158/// lets the session build the dispatch table with **no hardcoded vendor names**
159/// — an EP that returns no keys (the default) simply doesn't participate.
160///
161/// # Errors
162///
163/// Propagates [`EpError::DuplicateContextSource`] if two EPs declare the same
164/// `source` key (reject-duplicate-key — see [`EpContextRegistry::register`]).
165pub fn build_ep_context_registry<'a, I>(eps: I) -> Result<EpContextRegistry>
166where
167    I: IntoIterator<Item = (EpId, &'a dyn ExecutionProvider)>,
168{
169    let mut registry = EpContextRegistry::new();
170    for (id, ep) in eps {
171        let keys = ep.context_source_keys();
172        if keys.is_empty() {
173            continue;
174        }
175        registry.register(id, &keys)?;
176    }
177    Ok(registry)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::kernel::{Kernel, KernelMatch};
184    use crate::provider::{DeviceBuffer, EpConfig, Fence};
185    use onnx_runtime_ir::{DataType, DeviceId, DeviceType, Node, NodeId, Shape, TensorLayout};
186
187    /// A mock compiled EP that participates in `EPContext` dispatch. It declares
188    /// two `source` keys, produces a fixed context blob, and validates the blob
189    /// on load. All other `ExecutionProvider` methods are unused stubs.
190    struct MockCompiledEp {
191        source_keys: Vec<String>,
192    }
193
194    impl MockCompiledEp {
195        const BLOB: &'static [u8] = b"mock-compiled-context-v1";
196
197        fn new() -> Self {
198            // Keys come from "config", not hardcoded in dispatch logic (§55.6).
199            Self {
200                source_keys: vec!["MOCK".to_string(), "MockExecutionProvider".to_string()],
201            }
202        }
203    }
204
205    impl ExecutionProvider for MockCompiledEp {
206        fn name(&self) -> &str {
207            "mock_compiled_ep"
208        }
209
210        fn device_type(&self) -> DeviceType {
211            DeviceType::Custom(0)
212        }
213
214        fn device_id(&self) -> DeviceId {
215            DeviceId::new(DeviceType::Custom(0), 0)
216        }
217
218        fn initialize(&mut self, _config: &EpConfig) -> Result<()> {
219            Ok(())
220        }
221
222        fn shutdown(&mut self) -> Result<()> {
223            Ok(())
224        }
225
226        fn supports_op(
227            &self,
228            _op: &Node,
229            _opset: u64,
230            _shapes: &[Shape],
231            _input_dtypes: &[DataType],
232            _layouts: &[TensorLayout],
233        ) -> KernelMatch {
234            KernelMatch::unsupported("test EP supports no ops")
235        }
236
237        fn get_kernel(
238            &self,
239            _op: &Node,
240            _shapes: &[Vec<usize>],
241            _opset: u64,
242        ) -> Result<Box<dyn Kernel>> {
243            Err(EpError::NoEpForOp {
244                domain: "ai.onnx".to_string(),
245                op_type: "<mock>".to_string(),
246                opset: _opset,
247            })
248        }
249
250        fn allocate(&self, _size: usize, _alignment: usize) -> Result<DeviceBuffer> {
251            Err(EpError::NotInitialized)
252        }
253
254        fn deallocate(&self, _buffer: DeviceBuffer) -> Result<()> {
255            Ok(())
256        }
257
258        fn copy(&self, _src: &DeviceBuffer, _dst: &mut DeviceBuffer, _size: usize) -> Result<()> {
259            Ok(())
260        }
261
262        fn copy_async(
263            &self,
264            _src: &DeviceBuffer,
265            _dst: &mut DeviceBuffer,
266            _size: usize,
267        ) -> Result<Fence> {
268            Ok(Fence::default())
269        }
270
271        fn sync(&self) -> Result<()> {
272            Ok(())
273        }
274
275        // --- EPContext contract (§55) ---
276
277        fn context_source_keys(&self) -> Vec<String> {
278            self.source_keys.clone()
279        }
280
281        fn save_context(&self) -> Result<EpContext> {
282            Ok(EpContext::new(
283                self.name(),
284                "1.2.3",
285                Self::BLOB.to_vec(),
286                vec![NodeId(7)],
287                "mock-device",
288            ))
289        }
290
291        fn load_context(&self, ctx: &EpContext) -> Result<()> {
292            if ctx.data == Self::BLOB {
293                Ok(())
294            } else {
295                Err(EpError::KernelFailed("mock: unexpected context blob".to_string()))
296            }
297        }
298    }
299
300    /// A plain EP with no `EPContext` override — exercises the trait defaults.
301    struct PlainEp;
302
303    impl ExecutionProvider for PlainEp {
304        fn name(&self) -> &str {
305            "plain_ep"
306        }
307        fn device_type(&self) -> DeviceType {
308            DeviceType::Cpu
309        }
310        fn device_id(&self) -> DeviceId {
311            DeviceId::cpu()
312        }
313        fn initialize(&mut self, _config: &EpConfig) -> Result<()> {
314            Ok(())
315        }
316        fn shutdown(&mut self) -> Result<()> {
317            Ok(())
318        }
319        fn supports_op(
320            &self,
321            _op: &Node,
322            _opset: u64,
323            _shapes: &[Shape],
324            _input_dtypes: &[DataType],
325            _layouts: &[TensorLayout],
326        ) -> KernelMatch {
327            KernelMatch::unsupported("test EP supports no ops")
328        }
329        fn get_kernel(
330            &self,
331            _op: &Node,
332            _shapes: &[Vec<usize>],
333            _opset: u64,
334        ) -> Result<Box<dyn Kernel>> {
335            Err(EpError::NoEpForOp {
336                domain: "ai.onnx".to_string(),
337                op_type: "<plain>".to_string(),
338                opset: _opset,
339            })
340        }
341        fn allocate(&self, _size: usize, _alignment: usize) -> Result<DeviceBuffer> {
342            Err(EpError::NotInitialized)
343        }
344        fn deallocate(&self, _buffer: DeviceBuffer) -> Result<()> {
345            Ok(())
346        }
347        fn copy(&self, _src: &DeviceBuffer, _dst: &mut DeviceBuffer, _size: usize) -> Result<()> {
348            Ok(())
349        }
350        fn copy_async(
351            &self,
352            _src: &DeviceBuffer,
353            _dst: &mut DeviceBuffer,
354            _size: usize,
355        ) -> Result<Fence> {
356            Ok(Fence::default())
357        }
358        fn sync(&self) -> Result<()> {
359            Ok(())
360        }
361    }
362
363    #[test]
364    fn register_and_claim_by_source_key() {
365        let mock = MockCompiledEp::new();
366        let mut reg = EpContextRegistry::new();
367        let ep_id = EpId(3);
368        reg.register(ep_id, &mock.context_source_keys()).unwrap();
369
370        // Every declared key resolves to the mock's id.
371        assert_eq!(reg.claim(Some("MOCK")), Some(ep_id));
372        assert_eq!(reg.claim(Some("MockExecutionProvider")), Some(ep_id));
373        assert_eq!(reg.len(), 2);
374    }
375
376    #[test]
377    fn unmatched_and_absent_source_are_unclaimed() {
378        let mock = MockCompiledEp::new();
379        let mut reg = EpContextRegistry::new();
380        reg.register(EpId(0), &mock.context_source_keys()).unwrap();
381
382        // A different vendor's key is not registered → unclaimed (no guessing).
383        assert_eq!(reg.claim(Some("QNN")), None);
384        // Absent `source` attribute → unclaimed.
385        assert_eq!(reg.claim(None), None);
386    }
387
388    #[test]
389    fn duplicate_source_key_is_rejected() {
390        let mut reg = EpContextRegistry::new();
391        reg.register(EpId(0), &["MOCK".to_string()]).unwrap();
392
393        // A different EP claiming the same key is a configuration error.
394        let err = reg
395            .register(EpId(1), &["MOCK".to_string()])
396            .expect_err("duplicate source key must be rejected");
397        match err {
398            EpError::DuplicateContextSource {
399                source_key,
400                existing,
401                new,
402            } => {
403                assert_eq!(source_key, "MOCK");
404                assert_eq!(existing, EpId(0));
405                assert_eq!(new, EpId(1));
406            }
407            other => panic!("expected DuplicateContextSource, got {other:?}"),
408        }
409
410        // The original binding is untouched.
411        assert_eq!(reg.claim(Some("MOCK")), Some(EpId(0)));
412    }
413
414    #[test]
415    fn re_registering_same_binding_is_idempotent() {
416        let mut reg = EpContextRegistry::new();
417        reg.register(EpId(2), &["MOCK".to_string()]).unwrap();
418        // Same key, same EP: not a conflict.
419        reg.register(EpId(2), &["MOCK".to_string()]).unwrap();
420        assert_eq!(reg.claim(Some("MOCK")), Some(EpId(2)));
421        assert_eq!(reg.len(), 1);
422    }
423
424    #[test]
425    fn build_registry_from_eps_skips_non_participants() {
426        let mock = MockCompiledEp::new();
427        let plain = PlainEp;
428        let eps: Vec<(EpId, &dyn ExecutionProvider)> =
429            vec![(EpId(0), &plain), (EpId(1), &mock)];
430
431        let reg = build_ep_context_registry(eps).unwrap();
432
433        // The plain EP (empty keys) does not appear; the mock's keys map to it.
434        assert_eq!(reg.len(), 2);
435        assert_eq!(reg.claim(Some("MOCK")), Some(EpId(1)));
436        assert_eq!(reg.claim(Some("MockExecutionProvider")), Some(EpId(1)));
437    }
438
439    #[test]
440    fn build_registry_from_eps_propagates_duplicate_error() {
441        // Two mocks declaring the same keys under different ids must conflict.
442        let a = MockCompiledEp::new();
443        let b = MockCompiledEp::new();
444        let eps: Vec<(EpId, &dyn ExecutionProvider)> = vec![(EpId(0), &a), (EpId(1), &b)];
445
446        let err = build_ep_context_registry(eps)
447            .expect_err("two EPs on the same source key must conflict");
448        assert!(matches!(err, EpError::DuplicateContextSource { .. }));
449    }
450
451    #[test]
452    fn save_load_round_trip_preserves_bytes() {
453        let mock = MockCompiledEp::new();
454        let ctx = mock.save_context().unwrap();
455        assert_eq!(ctx.ep_name, "mock_compiled_ep");
456        assert_eq!(ctx.ep_version, "1.2.3");
457        assert_eq!(ctx.data, MockCompiledEp::BLOB);
458        assert_eq!(ctx.covered_nodes, vec![NodeId(7)]);
459        // The EP accepts its own saved context.
460        mock.load_context(&ctx).unwrap();
461
462        // A tampered blob is rejected.
463        let mut bad = ctx.clone();
464        bad.data.push(0xFF);
465        assert!(mock.load_context(&bad).is_err());
466    }
467
468    #[test]
469    fn plain_ep_defaults_are_empty_and_unsupported() {
470        let plain = PlainEp;
471        assert!(plain.context_source_keys().is_empty());
472        assert!(matches!(
473            plain.save_context(),
474            Err(EpError::UnsupportedContext { .. })
475        ));
476
477        let ctx = EpContext::default();
478        assert!(matches!(
479            plain.load_context(&ctx),
480            Err(EpError::UnsupportedContext { .. })
481        ));
482    }
483
484    #[test]
485    fn no_ep_for_context_error_carries_source() {
486        // Illustrates how session dispatch surfaces an unclaimed node (§55.3).
487        let reg = EpContextRegistry::new();
488        let source = Some("QNN");
489        let err = reg
490            .claim(source)
491            .ok_or_else(|| EpError::NoEpForContext {
492                source_key: source.map(str::to_owned),
493            })
494            .unwrap_err();
495        match err {
496            EpError::NoEpForContext { source_key } => {
497                assert_eq!(source_key.as_deref(), Some("QNN"))
498            }
499            other => panic!("expected NoEpForContext, got {other:?}"),
500        }
501    }
502}