Skip to main content

onnx_runtime_ep_api/
provider.rs

1//! The [`ExecutionProvider`] trait and its supporting types (§4.1).
2
3use std::ffi::c_void;
4use std::ptr::NonNull;
5
6use onnx_runtime_ir::{DataType, DeviceId, DeviceType, Graph, Node, NodeId, Shape, TensorLayout};
7
8use crate::epcontext::EpContext;
9use crate::error::{EpError, Result};
10use crate::kernel::{Kernel, KernelMatch};
11use crate::weight::ExecutionProviderCapabilities;
12
13/// Index of an EP within an [`crate::registry::EpRegistry`].
14#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
15pub struct EpId(pub u32);
16
17/// Opaque, namespaced configuration passed to [`ExecutionProvider::initialize`].
18#[derive(Clone, Debug, Default)]
19pub struct EpConfig {
20    /// Namespaced key/value options (e.g. `"cuda.arena_extend_strategy"`).
21    pub options: std::collections::HashMap<String, String>,
22}
23
24/// An owning handle to a single device allocation.
25///
26/// # Ownership & lifetime
27///
28/// A `DeviceBuffer` is the **sole owner** of the allocation it names. It is
29/// produced only by [`ExecutionProvider::allocate`] and released only by
30/// [`ExecutionProvider::deallocate`], which consumes it *by value*. The owning
31/// EP is both allocator and deallocator: the buffer records the [`DeviceId`]
32/// (hence which EP instance) that may free it, so a buffer must never be handed
33/// to a different EP. Ownership is unique — no two `DeviceBuffer`s ever alias
34/// the same allocation.
35///
36/// # No `Drop`
37///
38/// `DeviceBuffer` deliberately does **not** implement [`Drop`]. Freeing device
39/// memory generally needs the EP's context/stream (a CUDA context, an MLX
40/// queue, an allocator arena) that this bare handle does not carry, so a silent
41/// drop could not free correctly. Consequences:
42/// * Dropping a `DeviceBuffer` without passing it to `deallocate` **leaks** the
43///   allocation. It can never *double-free*, which is the memory-safety
44///   property we prioritize (plan §4.4).
45/// * The session layer owns the discipline of pairing every `allocate` with
46///   exactly one `deallocate`. Higher layers may wrap this handle in an
47///   RAII/`Arc` type that calls back into the EP; that policy lives above the
48///   EP contract, not here.
49///
50/// # Access
51///
52/// The base address is reachable only through [`DeviceBuffer::as_ptr`]
53/// (shared) and [`DeviceBuffer::as_mut_ptr`] (unique). Obtaining a pointer is
54/// safe; *dereferencing* it is `unsafe` and valid only on host-accessible
55/// devices ([`DeviceType::is_host_accessible`]) within the owning EP's context.
56///
57/// # Thread-safety
58///
59/// See the `Send`/`Sync` impls below for the exact invariant.
60#[derive(Debug)]
61pub struct DeviceBuffer {
62    device: DeviceId,
63    size: usize,
64    align: usize,
65    /// Non-null base address of the allocation. For CPU and MLX unified memory
66    /// this is a dereferenceable host pointer; for CUDA/ROCm it is an opaque
67    /// device address only meaningful inside the owning EP's context.
68    ptr: NonNull<c_void>,
69    /// Whether this handle *owns* the pointed-to allocation.
70    ///
71    /// [`BufferOwner::Owned`] (the default for [`DeviceBuffer::from_raw_parts`])
72    /// is the original contract: the owning EP must free it exactly once in
73    /// `deallocate`. Borrowed handles alias memory owned by *someone else*.
74    /// Read-only aliases come from [`DeviceBuffer::from_borrowed_parts`];
75    /// exclusive writable aliases come from
76    /// [`DeviceBuffer::from_borrowed_mut_parts`]. `deallocate` must **not** free
77    /// either kind.
78    owner: BufferOwner,
79}
80
81/// Whether a [`DeviceBuffer`] owns the allocation it names, or merely borrows
82/// (aliases) memory owned elsewhere.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum BufferOwner {
85    /// This handle is the sole owner; the owning EP frees it in `deallocate`.
86    Owned,
87    /// This handle aliases foreign memory (e.g. an mmap). `deallocate` must be
88    /// a no-op free; the real owner must outlive the buffer and every use of it.
89    Borrowed,
90    /// This handle has temporary exclusive write access to an allocation owned
91    /// elsewhere. Deallocation remains a no-op.
92    BorrowedMut,
93}
94
95impl DeviceBuffer {
96    /// Wrap a raw device allocation in an owning handle.
97    ///
98    /// # Safety
99    ///
100    /// The caller (the owning EP) must guarantee all of:
101    /// * `ptr` is non-null and points to the start of an allocation of at least
102    ///   `size` bytes on `device`, aligned to at least `align` bytes.
103    /// * The allocation was produced by `device`'s EP and will be freed exactly
104    ///   once, only by returning this handle to that EP's `deallocate` (or via
105    ///   an equivalent raw free of the pointer obtained from
106    ///   [`DeviceBuffer::into_raw`]).
107    /// * No other live `DeviceBuffer` aliases the same allocation.
108    ///
109    /// `align` must be a power of two (checked in debug builds).
110    pub unsafe fn from_raw_parts(
111        ptr: *mut c_void,
112        device: DeviceId,
113        size: usize,
114        align: usize,
115    ) -> Self {
116        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
117        Self {
118            device,
119            size,
120            align,
121            ptr: NonNull::new(ptr).expect("DeviceBuffer::from_raw_parts: null pointer"),
122            owner: BufferOwner::Owned,
123        }
124    }
125
126    /// Wrap **foreign, borrowed** memory in a non-owning `DeviceBuffer`.
127    ///
128    /// Unlike [`DeviceBuffer::from_raw_parts`], the returned handle does **not**
129    /// own the allocation: it aliases memory owned by someone else (for example
130    /// a `memmap2::Mmap` over an on-disk weight file). This lets an EP reference
131    /// initializer bytes zero-copy instead of allocating + copying them into
132    /// fresh RAM.
133    ///
134    /// [`is_borrowed`](DeviceBuffer::is_borrowed) returns `true`, and the owning
135    /// EP's `deallocate` must treat it as a **no-op free** (the guard checks
136    /// `is_borrowed()`). [`into_raw`](DeviceBuffer::into_raw) still yields the
137    /// raw pointer, but the caller must **not** free it.
138    ///
139    /// # Safety
140    ///
141    /// The caller must guarantee all of:
142    /// * `ptr` is non-null and points to the start of a readable region of at
143    ///   least `size` bytes on `device`, aligned to at least `align` bytes.
144    /// * The memory is owned by another object (e.g. an mmap) that **outlives
145    ///   this buffer and every use of it** (read via `as_ptr`). Nothing else may
146    ///   free or unmap it while this handle or any alias derived from it lives.
147    /// * The buffer is treated as **read-only**: it is never written through
148    ///   (`as_mut_ptr` must not be used to mutate borrowed memory) and is never
149    ///   passed to an EP's `deallocate` expecting a free — `deallocate` skips
150    ///   the free for borrowed buffers.
151    ///
152    /// `align` must be a power of two (checked in debug builds).
153    pub unsafe fn from_borrowed_parts(
154        ptr: *mut c_void,
155        device: DeviceId,
156        size: usize,
157        align: usize,
158    ) -> Self {
159        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
160        Self {
161            device,
162            size,
163            align,
164            ptr: NonNull::new(ptr).expect("DeviceBuffer::from_borrowed_parts: null pointer"),
165            owner: BufferOwner::Borrowed,
166        }
167    }
168
169    /// Wrap foreign memory in a non-owning, exclusively writable buffer handle.
170    ///
171    /// This is intended for persistent external output bindings: the real owner
172    /// retains the allocation while an executor temporarily writes through this
173    /// alias.
174    ///
175    /// # Safety
176    ///
177    /// The caller must guarantee all of:
178    /// * `ptr` names a non-null writable allocation of at least `size` bytes on
179    ///   `device`, aligned to at least `align` bytes.
180    /// * The real owner outlives this handle and every operation using it.
181    /// * No other writer accesses the allocation while this handle is live.
182    /// * This handle is never used to free the allocation; `deallocate` treats
183    ///   it as borrowed.
184    pub unsafe fn from_borrowed_mut_parts(
185        ptr: *mut c_void,
186        device: DeviceId,
187        size: usize,
188        align: usize,
189    ) -> Option<Self> {
190        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
191        Some(Self {
192            device,
193            size,
194            align,
195            ptr: NonNull::new(ptr)?,
196            owner: BufferOwner::BorrowedMut,
197        })
198    }
199
200    /// Whether this handle merely *borrows* (aliases) foreign memory rather than
201    /// owning it. A borrowed buffer must never be freed by `deallocate`.
202    pub fn is_borrowed(&self) -> bool {
203        matches!(self.owner, BufferOwner::Borrowed | BufferOwner::BorrowedMut)
204    }
205
206    /// The device this allocation lives on (and whose EP must free it).
207    pub fn device(&self) -> DeviceId {
208        self.device
209    }
210
211    /// Allocation size in bytes.
212    pub fn len(&self) -> usize {
213        self.size
214    }
215
216    /// Whether the allocation is zero-length.
217    pub fn is_empty(&self) -> bool {
218        self.size == 0
219    }
220
221    /// Alignment (bytes) the base pointer was allocated to.
222    pub fn alignment(&self) -> usize {
223        self.align
224    }
225
226    /// Shared base pointer. Safe to obtain; dereferencing is `unsafe` and only
227    /// sound on host-accessible devices within the owning EP's context.
228    pub fn as_ptr(&self) -> *const c_void {
229        self.ptr.as_ptr()
230    }
231
232    /// Unique mutable base pointer. Requires `&mut self` so the borrow checker
233    /// forbids two writers sharing one buffer — this is what makes the `Sync`
234    /// impl sound (a shared `&DeviceBuffer` can never hand out a writable
235    /// pointer through safe code).
236    pub fn as_mut_ptr(&mut self) -> *mut c_void {
237        self.ptr.as_ptr()
238    }
239
240    /// Consume the handle, returning the raw pointer *without* freeing it. For
241    /// an owned buffer the caller assumes the single-free obligation from
242    /// [`DeviceBuffer::from_raw_parts`]. For a **borrowed** buffer (see
243    /// [`DeviceBuffer::from_borrowed_parts`]) the pointer must **not** be freed;
244    /// check [`is_borrowed`](DeviceBuffer::is_borrowed) first if the caller
245    /// intends to free.
246    pub fn into_raw(self) -> *mut c_void {
247        self.ptr.as_ptr()
248    }
249}
250
251// SAFETY: `DeviceBuffer` is an owning *handle* — it stores only a base address
252// plus metadata and exposes no safe way to read or write the pointed-to memory
253// (all access goes through `as_ptr`/`as_mut_ptr`, which are safe to *call* but
254// `unsafe` to *use*). Moving the handle to another thread transfers ownership of
255// the address; this is sound for every allocator we target — host `malloc`,
256// CUDA device pointers, and MLX unified memory are all address-portable and not
257// thread-affine at the pointer level. Any data race on the *contents* is
258// prevented one layer up by `&`/`&mut` aliasing on `TensorView`/`TensorMut` and
259// by the scheduler, not by this type. If a future EP wires a genuinely
260// thread-affine allocator, it must wrap the handle in a non-`Send` owner rather
261// than weaken this invariant (plan §4.4 flags this for a dedicated review when
262// ep-cpu lands real memory).
263unsafe impl Send for DeviceBuffer {}
264// SAFETY: `&DeviceBuffer` grants no interior mutability — it can only produce a
265// `*const` via `as_ptr` (a plain address copy) and read `Copy` metadata, so
266// concurrent shared reads of the handle are race-free. Writing requires
267// `as_mut_ptr`, which needs `&mut self`; obtaining a writable pointer therefore
268// cannot happen through a shared reference in safe code. As with `Send`,
269// mutating the underlying memory is gated behind `unsafe` pointer use whose
270// synchronization is the caller's responsibility.
271unsafe impl Sync for DeviceBuffer {}
272
273/// A synchronization fence returned by async operations.
274#[derive(Debug, Default)]
275pub struct Fence {
276    pub id: u64,
277}
278
279/// Marker for an EP exported as an ORT-compatible C ABI plugin (Phase 2).
280#[derive(Debug, Default)]
281pub struct OrtPluginExport {
282    pub register_symbol: String,
283}
284
285/// Resolved-shape facts needed by an EP's structural capture-region policy.
286#[derive(Clone, Copy, Debug, PartialEq, Eq)]
287pub struct CaptureRegionShapeStatus {
288    /// Every present node input has a concrete shape before capture.
289    pub inputs_resolved: bool,
290    /// Every node output has a concrete shape before capture.
291    pub outputs_resolved: bool,
292}
293
294/// Structural reason an EP excludes a node from a device-graph capture region.
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub enum StructuralCaptureDecline {
297    /// Host-driven control-flow or sequence semantics.
298    HostControlFlowOrSequence,
299    /// A data-dependent output shape was unresolved before capture.
300    UnresolvedOutputShape,
301    /// A data-dependent input shape was unresolved before capture.
302    UnresolvedInputShape,
303}
304
305impl StructuralCaptureDecline {
306    /// Stable diagnostic text matching the executor's original capture audit.
307    pub const fn reason(self) -> &'static str {
308        match self {
309            Self::HostControlFlowOrSequence => {
310                "control-flow and sequence nodes are not device-graph capturable"
311            }
312            Self::UnresolvedOutputShape => {
313                "data-dependent output shape was unresolved before capture"
314            }
315            Self::UnresolvedInputShape => {
316                "data-dependent input shape was unresolved before capture"
317            }
318        }
319    }
320}
321
322/// The core EP interface. Every backend crate implements this (§4.1).
323pub trait ExecutionProvider: Send + Sync {
324    /// EP identifier (snake_case, e.g. `"cpu_ep"`, `"cuda_ep"`).
325    fn name(&self) -> &str;
326
327    fn device_type(&self) -> DeviceType;
328    fn device_id(&self) -> DeviceId;
329
330    /// Optional executor-to-EP capabilities. Stock EPs advertise none and
331    /// continue receiving resident [`crate::TensorView`] inputs.
332    fn capabilities(&self) -> ExecutionProviderCapabilities {
333        ExecutionProviderCapabilities::stock()
334    }
335
336    /// Initialize device resources / load libraries.
337    fn initialize(&mut self, config: &EpConfig) -> Result<()>;
338    /// Release device resources.
339    fn shutdown(&mut self) -> Result<()>;
340
341    /// Whether this EP can run `op` at the model's effective `opset` with the
342    /// given input shapes, dtypes, and layouts, and at what cost.
343    ///
344    /// Every [`KernelMatch::Unsupported`] result must carry an actionable reason:
345    /// state what the EP accepts and, where possible, how to fix the model or
346    /// registration rather than returning a bare decline.
347    fn supports_op(
348        &self,
349        op: &Node,
350        opset: u64,
351        shapes: &[Shape],
352        input_dtypes: &[DataType],
353        layouts: &[TensorLayout],
354    ) -> KernelMatch;
355
356    /// Get or create a kernel for `op` specialized to concrete `shapes`.
357    ///
358    /// `opset` is the effective operator-set version for `op`'s domain in the
359    /// owning graph. EPs use it to select opset-specialized kernels (e.g. the
360    /// opset-13 per-axis vs. the legacy opset-<13 2D-coercion `Softmax`).
361    fn get_kernel(&self, op: &Node, shapes: &[Vec<usize>], opset: u64) -> Result<Box<dyn Kernel>>;
362
363    /// Apply EP-owned structural policy to one prospective capture-region node.
364    ///
365    /// The executor supplies only graph structure and resolved-shape presence.
366    /// Kernel warmth and the selected compiled kernel's capture support remain
367    /// executor-owned mechanism and are checked only after this hook admits the
368    /// node. Implementations must decline when either shape-status field is
369    /// false; admitting an unresolved shape violates the executor contract. The
370    /// default preserves the original predicate precedence exactly.
371    fn plan_capture_region(
372        &self,
373        node: &Node,
374        shape_status: CaptureRegionShapeStatus,
375    ) -> Option<StructuralCaptureDecline> {
376        if is_control_flow_or_sequence(node) {
377            return Some(StructuralCaptureDecline::HostControlFlowOrSequence);
378        }
379        if !shape_status.outputs_resolved {
380            return Some(StructuralCaptureDecline::UnresolvedOutputShape);
381        }
382        if !shape_status.inputs_resolved {
383            return Some(StructuralCaptureDecline::UnresolvedInputShape);
384        }
385        None
386    }
387
388    /// Allocate device memory.
389    fn allocate(&self, size: usize, alignment: usize) -> Result<DeviceBuffer>;
390    /// Free device memory.
391    fn deallocate(&self, buffer: DeviceBuffer) -> Result<()>;
392
393    /// Synchronous copy (host↔device or device↔device).
394    fn copy(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<()>;
395    /// Asynchronous copy; returns a [`Fence`] to await.
396    fn copy_async(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<Fence>;
397
398    /// Whether this EP can select the first maximum f32 element on-device and
399    /// return the token id together with its capture-error status.
400    fn device_argmax_supported(&self) -> bool {
401        false
402    }
403
404    /// Launch an allocation-free device argmax over `elements` contiguous
405    /// `dtype` values (Float32 or Float16). `result` receives two native-endian
406    /// u32 values: token id, then the latching device capture-error bitmask.
407    fn device_argmax(
408        &self,
409        _logits: &DeviceBuffer,
410        _elements: usize,
411        _dtype: DataType,
412        _result: &mut DeviceBuffer,
413    ) -> Result<()> {
414        Err(EpError::KernelFailed(format!(
415            "{}: device argmax is not supported",
416            self.name()
417        )))
418    }
419
420    /// Begin recording the supplied, already-compiled kernel sequence into a
421    /// device graph. EPs without graph support reject the request.
422    fn begin_device_graph_capture(&self, _kernels: &[&dyn Kernel]) -> Result<()> {
423        Err(EpError::KernelFailed(format!(
424            "{}: device graph capture is not supported",
425            self.name()
426        )))
427    }
428
429    /// End device-graph capture and install the resulting executable.
430    fn end_device_graph_capture(&self) -> Result<()> {
431        Err(EpError::KernelFailed(format!(
432            "{}: device graph capture is not supported",
433            self.name()
434        )))
435    }
436
437    /// Abort an in-progress device-graph capture, returning the stream and
438    /// lifecycle to a clean idle state so a subsequent [`reset_device_graph`]
439    /// succeeds. Called on the error path of segmented capture when a node
440    /// fails mid-record: the capture must always be ended before reset, so the
441    /// stream is not left wedged in capture mode. EPs without device graphs have
442    /// nothing to abort.
443    ///
444    /// [`reset_device_graph`]: ExecutionProvider::reset_device_graph
445    fn abort_device_graph_capture(&self) -> Result<()> {
446        Ok(())
447    }
448
449    /// Replay the installed device graph.
450    ///
451    /// When the EP holds multiple captured **segments** (segmented capture), this
452    /// replays every installed segment in capture order. For the single-graph
453    /// fast path (one whole-subgraph capture) that is exactly the one graph.
454    fn replay_device_graph(&self) -> Result<()> {
455        Err(EpError::KernelFailed(format!(
456            "{}: device graph replay is not supported",
457            self.name()
458        )))
459    }
460
461    /// Replay one captured **segment** by its zero-based capture-order index.
462    ///
463    /// Segmented capture claims a whole subgraph even when only parts are
464    /// device-graph capturable: the executor captures each maximal capturable
465    /// run as its own segment and, at replay time, launches the segment graphs
466    /// in order while running the non-capturable seam nodes eagerly in between.
467    /// EPs without segmented graph support reject the request.
468    fn replay_device_graph_segment(&self, _index: usize) -> Result<()> {
469        Err(EpError::KernelFailed(format!(
470            "{}: segmented device graph replay is not supported",
471            self.name()
472        )))
473    }
474
475    /// Destroy any installed device graph before its referenced buffers move or
476    /// are released.
477    fn reset_device_graph(&self) -> Result<bool> {
478        Ok(false)
479    }
480
481    /// Read (without clearing) any latching device-side capture-safety error a
482    /// captured kernel recorded during graph replay, as a raw violation bitmask
483    /// (zero when none). EPs without device graphs report no error.
484    ///
485    /// The decode loop calls this at the per-step logits device→host sync so an
486    /// out-of-range bounds violation becomes a hard error before the produced
487    /// token is consumed, without adding a separate synchronization.
488    fn check_device_capture_error(&self) -> Result<u32> {
489        Ok(0)
490    }
491
492    /// Explicit device allocation/free counters, when the EP exposes them.
493    fn device_allocation_counts(&self) -> Option<(u64, u64)> {
494        None
495    }
496
497    /// Synchronously upload host bytes into a buffer owned by this EP.
498    fn copy_from_host(&self, src: &[u8], dst: &mut DeviceBuffer) -> Result<()> {
499        if !dst.device().is_host_accessible() {
500            return Err(EpError::KernelFailed(format!(
501                "{}: host upload is not implemented for device {:?}",
502                self.name(),
503                dst.device()
504            )));
505        }
506        if src.len() > dst.len() {
507            return Err(EpError::KernelFailed(format!(
508                "{}: host upload of {} bytes exceeds destination {} bytes",
509                self.name(),
510                src.len(),
511                dst.len()
512            )));
513        }
514        if src.is_empty() {
515            return Ok(());
516        }
517        // SAFETY: host accessibility is checked above, `dst` is uniquely
518        // borrowed, and its allocation is at least `src.len()` bytes.
519        unsafe {
520            std::ptr::copy_nonoverlapping(src.as_ptr(), dst.as_mut_ptr().cast(), src.len());
521        }
522        Ok(())
523    }
524
525    /// Synchronously upload host bytes into a byte range of a buffer owned by
526    /// this EP.
527    fn copy_from_host_at(
528        &self,
529        src: &[u8],
530        dst: &mut DeviceBuffer,
531        byte_offset: usize,
532    ) -> Result<()> {
533        let end = byte_offset.checked_add(src.len()).ok_or_else(|| {
534            EpError::KernelFailed(format!("{}: host upload range overflows", self.name()))
535        })?;
536        if end > dst.len() {
537            return Err(EpError::KernelFailed(format!(
538                "{}: host upload range {byte_offset}..{end} exceeds destination {} bytes",
539                self.name(),
540                dst.len()
541            )));
542        }
543        if src.is_empty() {
544            return Ok(());
545        }
546        if !dst.device().is_host_accessible() {
547            return Err(EpError::KernelFailed(format!(
548                "{}: ranged host upload is not implemented for device {:?}",
549                self.name(),
550                dst.device()
551            )));
552        }
553        // SAFETY: host accessibility and bounds are checked above, and `dst` is
554        // uniquely borrowed for the duration of the copy.
555        unsafe {
556            std::ptr::copy_nonoverlapping(
557                src.as_ptr(),
558                dst.as_mut_ptr().cast::<u8>().add(byte_offset),
559                src.len(),
560            );
561        }
562        Ok(())
563    }
564
565    /// Synchronously download a buffer owned by this EP into host bytes.
566    fn copy_to_host(&self, src: &DeviceBuffer, dst: &mut [u8]) -> Result<()> {
567        if !src.device().is_host_accessible() {
568            return Err(EpError::KernelFailed(format!(
569                "{}: host download is not implemented for device {:?}",
570                self.name(),
571                src.device()
572            )));
573        }
574        if dst.len() > src.len() {
575            return Err(EpError::KernelFailed(format!(
576                "{}: host download of {} bytes exceeds source {} bytes",
577                self.name(),
578                dst.len(),
579                src.len()
580            )));
581        }
582        if dst.is_empty() {
583            return Ok(());
584        }
585        // SAFETY: host accessibility is checked above, `dst` is uniquely
586        // borrowed, and `src` contains at least `dst.len()` readable bytes.
587        unsafe {
588            std::ptr::copy_nonoverlapping(src.as_ptr().cast(), dst.as_mut_ptr(), dst.len());
589        }
590        Ok(())
591    }
592
593    /// Block until all pending work on this EP completes.
594    fn sync(&self) -> Result<()>;
595
596    /// Export this EP as an ORT C ABI plugin, if supported (Phase 2).
597    fn as_ort_plugin(&self) -> Option<OrtPluginExport> {
598        None
599    }
600
601    /// EP-specific optimization passes, run after the generic optimizer.
602    fn custom_passes(&self) -> Vec<Box<dyn onnx_runtime_optimizer::OptimizationPass>> {
603        Vec::new()
604    }
605
606    /// Nodes this EP claims unconditionally (bypassing cost-model placement).
607    fn claim_nodes(&self, graph: &Graph) -> Vec<NodeId> {
608        let _ = graph;
609        Vec::new()
610    }
611
612    /// The `EPContext` node `source` key(s) this EP accepts for compiled-context
613    /// dispatch (`docs/ORT2.md` §55.6). The keys come from the EP's own
614    /// config/data — **never** hardcoded in loader/session dispatch. An empty
615    /// list (the default) means the EP does not participate in `EPContext`
616    /// (e.g. the pure-Rust CPU EP has no compile step).
617    fn context_source_keys(&self) -> Vec<String> {
618        Vec::new()
619    }
620
621    /// Produce the runtime [`EpContext`] for this EP's freshly compiled subgraph
622    /// (the §55.4 dump path calls this). Default: unsupported — an EP with no
623    /// compile step returns [`EpError::UnsupportedContext`].
624    fn save_context(&self) -> Result<EpContext> {
625        Err(EpError::UnsupportedContext {
626            ep: self.name().to_string(),
627        })
628    }
629
630    /// Restore this EP from a runtime [`EpContext`], skipping convert+compile
631    /// (the §55.3 load path calls this). Default: unsupported — an EP that does
632    /// not consume `EPContext` returns [`EpError::UnsupportedContext`].
633    fn load_context(&self, ctx: &EpContext) -> Result<()> {
634        let _ = ctx;
635        Err(EpError::UnsupportedContext {
636            ep: self.name().to_string(),
637        })
638    }
639}
640
641fn is_control_flow_or_sequence(node: &Node) -> bool {
642    if !(node.domain.is_empty() || node.domain == "ai.onnx") {
643        return false;
644    }
645    matches!(
646        node.op_type.as_str(),
647        "If" | "Loop"
648            | "Scan"
649            | "SequenceEmpty"
650            | "SequenceConstruct"
651            | "SequenceInsert"
652            | "SequenceErase"
653            | "SequenceAt"
654            | "SequenceLength"
655            | "SplitToSequence"
656            | "ConcatFromSequence"
657    )
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    fn _assert_send_sync<T: Send + Sync>() {}
665
666    /// Leak a boxed byte slice as a stand-in host allocation.
667    fn host_alloc(size: usize, align: usize) -> DeviceBuffer {
668        let boxed = vec![0u8; size].into_boxed_slice();
669        let ptr = Box::into_raw(boxed) as *mut c_void;
670        // SAFETY: `ptr` is a valid, unique, non-null allocation of `size` bytes
671        // on the host, aligned to the allocator's guarantee (>= 1); we treat the
672        // CPU EP as its owner and free it exactly once in `host_free`.
673        unsafe { DeviceBuffer::from_raw_parts(ptr, DeviceId::cpu(), size, align) }
674    }
675
676    fn host_free(buf: DeviceBuffer) {
677        let size = buf.len();
678        let ptr = buf.into_raw() as *mut u8;
679        // SAFETY: reconstruct the exact `Box<[u8]>` leaked in `host_alloc` so it
680        // is freed once. `into_raw` consumed the handle, so no alias remains.
681        unsafe {
682            drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, size)));
683        }
684    }
685
686    #[test]
687    fn device_buffer_is_send_sync() {
688        _assert_send_sync::<DeviceBuffer>();
689    }
690
691    #[test]
692    fn buffer_metadata_and_single_free() {
693        let mut buf = host_alloc(128, 64);
694        assert_eq!(buf.len(), 128);
695        assert!(!buf.is_empty());
696        assert_eq!(buf.alignment(), 64);
697        assert_eq!(buf.device(), DeviceId::cpu());
698        assert!(!buf.as_ptr().is_null());
699        assert!(!buf.as_mut_ptr().is_null());
700        // Single free path — a double free here would trip ASan/Miri.
701        host_free(buf);
702    }
703
704    #[test]
705    fn buffer_moves_across_thread() {
706        let buf = host_alloc(64, 16);
707        let base = buf.as_ptr() as usize;
708        let handle = std::thread::spawn(move || {
709            assert_eq!(buf.len(), 64);
710            assert_eq!(buf.as_ptr() as usize, base);
711            buf // hand ownership back so the main thread frees it once
712        });
713        let buf = handle.join().unwrap();
714        host_free(buf);
715    }
716
717    #[test]
718    fn owned_buffer_is_not_borrowed() {
719        let buf = host_alloc(32, 16);
720        assert!(
721            !buf.is_borrowed(),
722            "from_raw_parts must produce an owned buffer"
723        );
724        host_free(buf);
725    }
726
727    /// A borrowed buffer aliases memory owned by someone else (here a `Vec`):
728    /// it reports `is_borrowed()`, exposes the aliased pointer, and consuming it
729    /// via `into_raw` must NOT free the backing — the `Vec` stays valid.
730    #[test]
731    fn borrowed_buffer_aliases_without_owning() {
732        let mut backing = vec![7u8; 64];
733        let ptr = backing.as_mut_ptr() as *mut c_void;
734        // SAFETY: `ptr`/`len` name `backing`'s live allocation (aligned to 1);
735        // `backing` outlives the buffer and every use below, and we never write
736        // through the borrowed handle.
737        let buf = unsafe { DeviceBuffer::from_borrowed_parts(ptr, DeviceId::cpu(), 64, 1) };
738        assert!(buf.is_borrowed());
739        assert_eq!(buf.len(), 64);
740        assert_eq!(buf.as_ptr(), ptr as *const c_void);
741        // Consume without freeing: `into_raw` must never free a borrowed buffer.
742        let raw = buf.into_raw();
743        assert_eq!(raw, ptr);
744        // `backing` is still fully valid — a free would be a use-after-free here.
745        assert!(backing.iter().all(|&b| b == 7));
746        backing[0] = 9;
747        assert_eq!(backing[0], 9);
748    }
749
750    #[test]
751    fn borrowed_mut_buffer_writes_without_owning() {
752        let mut backing = vec![0u8; 8];
753        let ptr = backing.as_mut_ptr() as *mut c_void;
754        // SAFETY: `backing` exclusively owns this writable region and outlives
755        // the temporary alias.
756        let mut buffer =
757            unsafe { DeviceBuffer::from_borrowed_mut_parts(ptr, DeviceId::cpu(), 8, 1) }
758                .expect("non-null backing");
759        assert!(buffer.is_borrowed());
760        // SAFETY: the alias has exclusive access to all eight backing bytes.
761        unsafe {
762            std::ptr::copy_nonoverlapping([1u8, 2, 3].as_ptr(), buffer.as_mut_ptr().cast(), 3);
763        }
764        assert_eq!(buffer.into_raw(), ptr);
765        assert_eq!(&backing[..3], &[1, 2, 3]);
766        assert!(
767            unsafe {
768                DeviceBuffer::from_borrowed_mut_parts(std::ptr::null_mut(), DeviceId::cpu(), 0, 1)
769            }
770            .is_none()
771        );
772    }
773}