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(
296                    "mock: unexpected context blob".to_string(),
297                ))
298            }
299        }
300    }
301
302    /// A plain EP with no `EPContext` override — exercises the trait defaults.
303    struct PlainEp;
304
305    impl ExecutionProvider for PlainEp {
306        fn name(&self) -> &str {
307            "plain_ep"
308        }
309        fn device_type(&self) -> DeviceType {
310            DeviceType::Cpu
311        }
312        fn device_id(&self) -> DeviceId {
313            DeviceId::cpu()
314        }
315        fn initialize(&mut self, _config: &EpConfig) -> Result<()> {
316            Ok(())
317        }
318        fn shutdown(&mut self) -> Result<()> {
319            Ok(())
320        }
321        fn supports_op(
322            &self,
323            _op: &Node,
324            _opset: u64,
325            _shapes: &[Shape],
326            _input_dtypes: &[DataType],
327            _layouts: &[TensorLayout],
328        ) -> KernelMatch {
329            KernelMatch::unsupported("test EP supports no ops")
330        }
331        fn get_kernel(
332            &self,
333            _op: &Node,
334            _shapes: &[Vec<usize>],
335            _opset: u64,
336        ) -> Result<Box<dyn Kernel>> {
337            Err(EpError::NoEpForOp {
338                domain: "ai.onnx".to_string(),
339                op_type: "<plain>".to_string(),
340                opset: _opset,
341            })
342        }
343        fn allocate(&self, _size: usize, _alignment: usize) -> Result<DeviceBuffer> {
344            Err(EpError::NotInitialized)
345        }
346        fn deallocate(&self, _buffer: DeviceBuffer) -> Result<()> {
347            Ok(())
348        }
349        fn copy(&self, _src: &DeviceBuffer, _dst: &mut DeviceBuffer, _size: usize) -> Result<()> {
350            Ok(())
351        }
352        fn copy_async(
353            &self,
354            _src: &DeviceBuffer,
355            _dst: &mut DeviceBuffer,
356            _size: usize,
357        ) -> Result<Fence> {
358            Ok(Fence::default())
359        }
360        fn sync(&self) -> Result<()> {
361            Ok(())
362        }
363    }
364
365    #[test]
366    fn register_and_claim_by_source_key() {
367        let mock = MockCompiledEp::new();
368        let mut reg = EpContextRegistry::new();
369        let ep_id = EpId(3);
370        reg.register(ep_id, &mock.context_source_keys()).unwrap();
371
372        // Every declared key resolves to the mock's id.
373        assert_eq!(reg.claim(Some("MOCK")), Some(ep_id));
374        assert_eq!(reg.claim(Some("MockExecutionProvider")), Some(ep_id));
375        assert_eq!(reg.len(), 2);
376    }
377
378    #[test]
379    fn unmatched_and_absent_source_are_unclaimed() {
380        let mock = MockCompiledEp::new();
381        let mut reg = EpContextRegistry::new();
382        reg.register(EpId(0), &mock.context_source_keys()).unwrap();
383
384        // A different vendor's key is not registered → unclaimed (no guessing).
385        assert_eq!(reg.claim(Some("QNN")), None);
386        // Absent `source` attribute → unclaimed.
387        assert_eq!(reg.claim(None), None);
388    }
389
390    #[test]
391    fn duplicate_source_key_is_rejected() {
392        let mut reg = EpContextRegistry::new();
393        reg.register(EpId(0), &["MOCK".to_string()]).unwrap();
394
395        // A different EP claiming the same key is a configuration error.
396        let err = reg
397            .register(EpId(1), &["MOCK".to_string()])
398            .expect_err("duplicate source key must be rejected");
399        match err {
400            EpError::DuplicateContextSource {
401                source_key,
402                existing,
403                new,
404            } => {
405                assert_eq!(source_key, "MOCK");
406                assert_eq!(existing, EpId(0));
407                assert_eq!(new, EpId(1));
408            }
409            other => panic!("expected DuplicateContextSource, got {other:?}"),
410        }
411
412        // The original binding is untouched.
413        assert_eq!(reg.claim(Some("MOCK")), Some(EpId(0)));
414    }
415
416    #[test]
417    fn re_registering_same_binding_is_idempotent() {
418        let mut reg = EpContextRegistry::new();
419        reg.register(EpId(2), &["MOCK".to_string()]).unwrap();
420        // Same key, same EP: not a conflict.
421        reg.register(EpId(2), &["MOCK".to_string()]).unwrap();
422        assert_eq!(reg.claim(Some("MOCK")), Some(EpId(2)));
423        assert_eq!(reg.len(), 1);
424    }
425
426    #[test]
427    fn build_registry_from_eps_skips_non_participants() {
428        let mock = MockCompiledEp::new();
429        let plain = PlainEp;
430        let eps: Vec<(EpId, &dyn ExecutionProvider)> = vec![(EpId(0), &plain), (EpId(1), &mock)];
431
432        let reg = build_ep_context_registry(eps).unwrap();
433
434        // The plain EP (empty keys) does not appear; the mock's keys map to it.
435        assert_eq!(reg.len(), 2);
436        assert_eq!(reg.claim(Some("MOCK")), Some(EpId(1)));
437        assert_eq!(reg.claim(Some("MockExecutionProvider")), Some(EpId(1)));
438    }
439
440    #[test]
441    fn build_registry_from_eps_propagates_duplicate_error() {
442        // Two mocks declaring the same keys under different ids must conflict.
443        let a = MockCompiledEp::new();
444        let b = MockCompiledEp::new();
445        let eps: Vec<(EpId, &dyn ExecutionProvider)> = vec![(EpId(0), &a), (EpId(1), &b)];
446
447        let err = build_ep_context_registry(eps)
448            .expect_err("two EPs on the same source key must conflict");
449        assert!(matches!(err, EpError::DuplicateContextSource { .. }));
450    }
451
452    #[test]
453    fn save_load_round_trip_preserves_bytes() {
454        let mock = MockCompiledEp::new();
455        let ctx = mock.save_context().unwrap();
456        assert_eq!(ctx.ep_name, "mock_compiled_ep");
457        assert_eq!(ctx.ep_version, "1.2.3");
458        assert_eq!(ctx.data, MockCompiledEp::BLOB);
459        assert_eq!(ctx.covered_nodes, vec![NodeId(7)]);
460        // The EP accepts its own saved context.
461        mock.load_context(&ctx).unwrap();
462
463        // A tampered blob is rejected.
464        let mut bad = ctx.clone();
465        bad.data.push(0xFF);
466        assert!(mock.load_context(&bad).is_err());
467    }
468
469    #[test]
470    fn plain_ep_defaults_are_empty_and_unsupported() {
471        let plain = PlainEp;
472        assert!(plain.context_source_keys().is_empty());
473        assert!(matches!(
474            plain.save_context(),
475            Err(EpError::UnsupportedContext { .. })
476        ));
477
478        let ctx = EpContext::default();
479        assert!(matches!(
480            plain.load_context(&ctx),
481            Err(EpError::UnsupportedContext { .. })
482        ));
483    }
484
485    #[test]
486    fn no_ep_for_context_error_carries_source() {
487        // Illustrates how session dispatch surfaces an unclaimed node (§55.3).
488        let reg = EpContextRegistry::new();
489        let source = Some("QNN");
490        let err = reg
491            .claim(source)
492            .ok_or_else(|| EpError::NoEpForContext {
493                source_key: source.map(str::to_owned),
494            })
495            .unwrap_err();
496        match err {
497            EpError::NoEpForContext { source_key } => {
498                assert_eq!(source_key.as_deref(), Some("QNN"))
499            }
500            other => panic!("expected NoEpForContext, got {other:?}"),
501        }
502    }
503}