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