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::{DeviceId, DeviceType, Graph, Node, NodeId, Shape, TensorLayout};
7
8use crate::error::{EpError, Result};
9use crate::epcontext::EpContext;
10use crate::kernel::{Kernel, KernelMatch};
11
12/// Index of an EP within an [`crate::registry::EpRegistry`].
13#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
14pub struct EpId(pub u32);
15
16/// Opaque, namespaced configuration passed to [`ExecutionProvider::initialize`].
17#[derive(Clone, Debug, Default)]
18pub struct EpConfig {
19    /// Namespaced key/value options (e.g. `"cuda.arena_extend_strategy"`).
20    pub options: std::collections::HashMap<String, String>,
21}
22
23/// An owning handle to a single device allocation.
24///
25/// # Ownership & lifetime
26///
27/// A `DeviceBuffer` is the **sole owner** of the allocation it names. It is
28/// produced only by [`ExecutionProvider::allocate`] and released only by
29/// [`ExecutionProvider::deallocate`], which consumes it *by value*. The owning
30/// EP is both allocator and deallocator: the buffer records the [`DeviceId`]
31/// (hence which EP instance) that may free it, so a buffer must never be handed
32/// to a different EP. Ownership is unique — no two `DeviceBuffer`s ever alias
33/// the same allocation.
34///
35/// # No `Drop`
36///
37/// `DeviceBuffer` deliberately does **not** implement [`Drop`]. Freeing device
38/// memory generally needs the EP's context/stream (a CUDA context, an MLX
39/// queue, an allocator arena) that this bare handle does not carry, so a silent
40/// drop could not free correctly. Consequences:
41/// * Dropping a `DeviceBuffer` without passing it to `deallocate` **leaks** the
42///   allocation. It can never *double-free*, which is the memory-safety
43///   property we prioritize (plan §4.4).
44/// * The session layer owns the discipline of pairing every `allocate` with
45///   exactly one `deallocate`. Higher layers may wrap this handle in an
46///   RAII/`Arc` type that calls back into the EP; that policy lives above the
47///   EP contract, not here.
48///
49/// # Access
50///
51/// The base address is reachable only through [`DeviceBuffer::as_ptr`]
52/// (shared) and [`DeviceBuffer::as_mut_ptr`] (unique). Obtaining a pointer is
53/// safe; *dereferencing* it is `unsafe` and valid only on host-accessible
54/// devices ([`DeviceType::is_host_accessible`]) within the owning EP's context.
55///
56/// # Thread-safety
57///
58/// See the `Send`/`Sync` impls below for the exact invariant.
59#[derive(Debug)]
60pub struct DeviceBuffer {
61    device: DeviceId,
62    size: usize,
63    align: usize,
64    /// Non-null base address of the allocation. For CPU and MLX unified memory
65    /// this is a dereferenceable host pointer; for CUDA/ROCm it is an opaque
66    /// device address only meaningful inside the owning EP's context.
67    ptr: NonNull<c_void>,
68}
69
70impl DeviceBuffer {
71    /// Wrap a raw device allocation in an owning handle.
72    ///
73    /// # Safety
74    ///
75    /// The caller (the owning EP) must guarantee all of:
76    /// * `ptr` is non-null and points to the start of an allocation of at least
77    ///   `size` bytes on `device`, aligned to at least `align` bytes.
78    /// * The allocation was produced by `device`'s EP and will be freed exactly
79    ///   once, only by returning this handle to that EP's `deallocate` (or via
80    ///   an equivalent raw free of the pointer obtained from
81    ///   [`DeviceBuffer::into_raw`]).
82    /// * No other live `DeviceBuffer` aliases the same allocation.
83    ///
84    /// `align` must be a power of two (checked in debug builds).
85    pub unsafe fn from_raw_parts(
86        ptr: *mut c_void,
87        device: DeviceId,
88        size: usize,
89        align: usize,
90    ) -> Self {
91        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
92        Self {
93            device,
94            size,
95            align,
96            ptr: NonNull::new(ptr).expect("DeviceBuffer::from_raw_parts: null pointer"),
97        }
98    }
99
100    /// The device this allocation lives on (and whose EP must free it).
101    pub fn device(&self) -> DeviceId {
102        self.device
103    }
104
105    /// Allocation size in bytes.
106    pub fn len(&self) -> usize {
107        self.size
108    }
109
110    /// Whether the allocation is zero-length.
111    pub fn is_empty(&self) -> bool {
112        self.size == 0
113    }
114
115    /// Alignment (bytes) the base pointer was allocated to.
116    pub fn alignment(&self) -> usize {
117        self.align
118    }
119
120    /// Shared base pointer. Safe to obtain; dereferencing is `unsafe` and only
121    /// sound on host-accessible devices within the owning EP's context.
122    pub fn as_ptr(&self) -> *const c_void {
123        self.ptr.as_ptr()
124    }
125
126    /// Unique mutable base pointer. Requires `&mut self` so the borrow checker
127    /// forbids two writers sharing one buffer — this is what makes the `Sync`
128    /// impl sound (a shared `&DeviceBuffer` can never hand out a writable
129    /// pointer through safe code).
130    pub fn as_mut_ptr(&mut self) -> *mut c_void {
131        self.ptr.as_ptr()
132    }
133
134    /// Consume the handle, returning the raw pointer *without* freeing it. The
135    /// caller assumes the single-free obligation from [`DeviceBuffer::from_raw_parts`].
136    pub fn into_raw(self) -> *mut c_void {
137        self.ptr.as_ptr()
138    }
139}
140
141// SAFETY: `DeviceBuffer` is an owning *handle* — it stores only a base address
142// plus metadata and exposes no safe way to read or write the pointed-to memory
143// (all access goes through `as_ptr`/`as_mut_ptr`, which are safe to *call* but
144// `unsafe` to *use*). Moving the handle to another thread transfers ownership of
145// the address; this is sound for every allocator we target — host `malloc`,
146// CUDA device pointers, and MLX unified memory are all address-portable and not
147// thread-affine at the pointer level. Any data race on the *contents* is
148// prevented one layer up by `&`/`&mut` aliasing on `TensorView`/`TensorMut` and
149// by the scheduler, not by this type. If a future EP wires a genuinely
150// thread-affine allocator, it must wrap the handle in a non-`Send` owner rather
151// than weaken this invariant (plan §4.4 flags this for a dedicated review when
152// ep-cpu lands real memory).
153unsafe impl Send for DeviceBuffer {}
154// SAFETY: `&DeviceBuffer` grants no interior mutability — it can only produce a
155// `*const` via `as_ptr` (a plain address copy) and read `Copy` metadata, so
156// concurrent shared reads of the handle are race-free. Writing requires
157// `as_mut_ptr`, which needs `&mut self`; obtaining a writable pointer therefore
158// cannot happen through a shared reference in safe code. As with `Send`,
159// mutating the underlying memory is gated behind `unsafe` pointer use whose
160// synchronization is the caller's responsibility.
161unsafe impl Sync for DeviceBuffer {}
162
163/// A synchronization fence returned by async operations.
164#[derive(Debug, Default)]
165pub struct Fence {
166    pub id: u64,
167}
168
169/// Marker for an EP exported as an ORT-compatible C ABI plugin (Phase 2).
170#[derive(Debug, Default)]
171pub struct OrtPluginExport {
172    pub register_symbol: String,
173}
174
175/// An EP-specific optimization pass.
176///
177/// Placeholder trait: the full pass pipeline lives in `onnx-runtime-optimizer`
178/// (Phase 2). Defined here so [`ExecutionProvider::custom_passes`] can name it
179/// without a Phase 2 crate dependency.
180pub trait OptimizerPass: Send + Sync {
181    fn name(&self) -> &str;
182}
183
184/// The core EP interface. Every backend crate implements this (§4.1).
185pub trait ExecutionProvider: Send + Sync {
186    /// EP identifier (snake_case, e.g. `"cpu_ep"`, `"cuda_ep"`).
187    fn name(&self) -> &str;
188
189    fn device_type(&self) -> DeviceType;
190    fn device_id(&self) -> DeviceId;
191
192    /// Initialize device resources / load libraries.
193    fn initialize(&mut self, config: &EpConfig) -> Result<()>;
194    /// Release device resources.
195    fn shutdown(&mut self) -> Result<()>;
196
197    /// Whether this EP can run `op` with the given input shapes and layouts,
198    /// and at what cost.
199    fn supports_op(&self, op: &Node, shapes: &[Shape], layouts: &[TensorLayout]) -> KernelMatch;
200
201    /// Get or create a kernel for `op` specialized to concrete `shapes`.
202    ///
203    /// `opset` is the effective operator-set version for `op`'s domain in the
204    /// owning graph. EPs use it to select opset-specialized kernels (e.g. the
205    /// opset-13 per-axis vs. the legacy opset-<13 2D-coercion `Softmax`).
206    fn get_kernel(&self, op: &Node, shapes: &[Vec<usize>], opset: u64)
207        -> Result<Box<dyn Kernel>>;
208
209    /// Allocate device memory.
210    fn allocate(&self, size: usize, alignment: usize) -> Result<DeviceBuffer>;
211    /// Free device memory.
212    fn deallocate(&self, buffer: DeviceBuffer) -> Result<()>;
213
214    /// Synchronous copy (host↔device or device↔device).
215    fn copy(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<()>;
216    /// Asynchronous copy; returns a [`Fence`] to await.
217    fn copy_async(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<Fence>;
218
219    /// Block until all pending work on this EP completes.
220    fn sync(&self) -> Result<()>;
221
222    /// Export this EP as an ORT C ABI plugin, if supported (Phase 2).
223    fn as_ort_plugin(&self) -> Option<OrtPluginExport> {
224        None
225    }
226
227    /// EP-specific optimization passes, run after the generic optimizer.
228    fn custom_passes(&self) -> Vec<Box<dyn OptimizerPass>> {
229        Vec::new()
230    }
231
232    /// Nodes this EP claims unconditionally (bypassing cost-model placement).
233    fn claim_nodes(&self, graph: &Graph) -> Vec<NodeId> {
234        let _ = graph;
235        Vec::new()
236    }
237
238    /// The `EPContext` node `source` key(s) this EP accepts for compiled-context
239    /// dispatch (`docs/ORT2.md` §55.6). The keys come from the EP's own
240    /// config/data — **never** hardcoded in loader/session dispatch. An empty
241    /// list (the default) means the EP does not participate in `EPContext`
242    /// (e.g. the pure-Rust CPU EP has no compile step).
243    fn context_source_keys(&self) -> Vec<String> {
244        Vec::new()
245    }
246
247    /// Produce the runtime [`EpContext`] for this EP's freshly compiled subgraph
248    /// (the §55.4 dump path calls this). Default: unsupported — an EP with no
249    /// compile step returns [`EpError::UnsupportedContext`].
250    fn save_context(&self) -> Result<EpContext> {
251        Err(EpError::UnsupportedContext {
252            ep: self.name().to_string(),
253        })
254    }
255
256    /// Restore this EP from a runtime [`EpContext`], skipping convert+compile
257    /// (the §55.3 load path calls this). Default: unsupported — an EP that does
258    /// not consume `EPContext` returns [`EpError::UnsupportedContext`].
259    fn load_context(&self, ctx: &EpContext) -> Result<()> {
260        let _ = ctx;
261        Err(EpError::UnsupportedContext {
262            ep: self.name().to_string(),
263        })
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn _assert_send_sync<T: Send + Sync>() {}
272
273    /// Leak a boxed byte slice as a stand-in host allocation.
274    fn host_alloc(size: usize, align: usize) -> DeviceBuffer {
275        let boxed = vec![0u8; size].into_boxed_slice();
276        let ptr = Box::into_raw(boxed) as *mut c_void;
277        // SAFETY: `ptr` is a valid, unique, non-null allocation of `size` bytes
278        // on the host, aligned to the allocator's guarantee (>= 1); we treat the
279        // CPU EP as its owner and free it exactly once in `host_free`.
280        unsafe { DeviceBuffer::from_raw_parts(ptr, DeviceId::cpu(), size, align) }
281    }
282
283    fn host_free(buf: DeviceBuffer) {
284        let size = buf.len();
285        let ptr = buf.into_raw() as *mut u8;
286        // SAFETY: reconstruct the exact `Box<[u8]>` leaked in `host_alloc` so it
287        // is freed once. `into_raw` consumed the handle, so no alias remains.
288        unsafe {
289            drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, size)));
290        }
291    }
292
293    #[test]
294    fn device_buffer_is_send_sync() {
295        _assert_send_sync::<DeviceBuffer>();
296    }
297
298    #[test]
299    fn buffer_metadata_and_single_free() {
300        let mut buf = host_alloc(128, 64);
301        assert_eq!(buf.len(), 128);
302        assert!(!buf.is_empty());
303        assert_eq!(buf.alignment(), 64);
304        assert_eq!(buf.device(), DeviceId::cpu());
305        assert!(!buf.as_ptr().is_null());
306        assert!(!buf.as_mut_ptr().is_null());
307        // Single free path — a double free here would trip ASan/Miri.
308        host_free(buf);
309    }
310
311    #[test]
312    fn buffer_moves_across_thread() {
313        let buf = host_alloc(64, 16);
314        let base = buf.as_ptr() as usize;
315        let handle = std::thread::spawn(move || {
316            assert_eq!(buf.len(), 64);
317            assert_eq!(buf.as_ptr() as usize, base);
318            buf // hand ownership back so the main thread frees it once
319        });
320        let buf = handle.join().unwrap();
321        host_free(buf);
322    }
323}