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    /// Whether this handle *owns* the pointed-to allocation.
69    ///
70    /// [`BufferOwner::Owned`] (the default for [`DeviceBuffer::from_raw_parts`])
71    /// is the original contract: the owning EP must free it exactly once in
72    /// `deallocate`. [`BufferOwner::Borrowed`] (from
73    /// [`DeviceBuffer::from_borrowed_parts`]) aliases memory owned by *someone
74    /// else* (e.g. an mmap'd weight file) — `deallocate` must **not** free it
75    /// and it must never be written through.
76    owner: BufferOwner,
77}
78
79/// Whether a [`DeviceBuffer`] owns the allocation it names, or merely borrows
80/// (aliases) memory owned elsewhere.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum BufferOwner {
83    /// This handle is the sole owner; the owning EP frees it in `deallocate`.
84    Owned,
85    /// This handle aliases foreign memory (e.g. an mmap). `deallocate` must be
86    /// a no-op free; the real owner must outlive the buffer and every use of it.
87    Borrowed,
88}
89
90impl DeviceBuffer {
91    /// Wrap a raw device allocation in an owning handle.
92    ///
93    /// # Safety
94    ///
95    /// The caller (the owning EP) must guarantee all of:
96    /// * `ptr` is non-null and points to the start of an allocation of at least
97    ///   `size` bytes on `device`, aligned to at least `align` bytes.
98    /// * The allocation was produced by `device`'s EP and will be freed exactly
99    ///   once, only by returning this handle to that EP's `deallocate` (or via
100    ///   an equivalent raw free of the pointer obtained from
101    ///   [`DeviceBuffer::into_raw`]).
102    /// * No other live `DeviceBuffer` aliases the same allocation.
103    ///
104    /// `align` must be a power of two (checked in debug builds).
105    pub unsafe fn from_raw_parts(
106        ptr: *mut c_void,
107        device: DeviceId,
108        size: usize,
109        align: usize,
110    ) -> Self {
111        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
112        Self {
113            device,
114            size,
115            align,
116            ptr: NonNull::new(ptr).expect("DeviceBuffer::from_raw_parts: null pointer"),
117            owner: BufferOwner::Owned,
118        }
119    }
120
121    /// Wrap **foreign, borrowed** memory in a non-owning `DeviceBuffer`.
122    ///
123    /// Unlike [`DeviceBuffer::from_raw_parts`], the returned handle does **not**
124    /// own the allocation: it aliases memory owned by someone else (for example
125    /// a `memmap2::Mmap` over an on-disk weight file). This lets an EP reference
126    /// initializer bytes zero-copy instead of allocating + copying them into
127    /// fresh RAM.
128    ///
129    /// [`is_borrowed`](DeviceBuffer::is_borrowed) returns `true`, and the owning
130    /// EP's `deallocate` must treat it as a **no-op free** (the guard checks
131    /// `is_borrowed()`). [`into_raw`](DeviceBuffer::into_raw) still yields the
132    /// raw pointer, but the caller must **not** free it.
133    ///
134    /// # Safety
135    ///
136    /// The caller must guarantee all of:
137    /// * `ptr` is non-null and points to the start of a readable region of at
138    ///   least `size` bytes on `device`, aligned to at least `align` bytes.
139    /// * The memory is owned by another object (e.g. an mmap) that **outlives
140    ///   this buffer and every use of it** (read via `as_ptr`). Nothing else may
141    ///   free or unmap it while this handle or any alias derived from it lives.
142    /// * The buffer is treated as **read-only**: it is never written through
143    ///   (`as_mut_ptr` must not be used to mutate borrowed memory) and is never
144    ///   passed to an EP's `deallocate` expecting a free — `deallocate` skips
145    ///   the free for borrowed buffers.
146    ///
147    /// `align` must be a power of two (checked in debug builds).
148    pub unsafe fn from_borrowed_parts(
149        ptr: *mut c_void,
150        device: DeviceId,
151        size: usize,
152        align: usize,
153    ) -> Self {
154        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
155        Self {
156            device,
157            size,
158            align,
159            ptr: NonNull::new(ptr).expect("DeviceBuffer::from_borrowed_parts: null pointer"),
160            owner: BufferOwner::Borrowed,
161        }
162    }
163
164    /// Whether this handle merely *borrows* (aliases) foreign memory rather than
165    /// owning it. A borrowed buffer must never be freed by `deallocate`.
166    pub fn is_borrowed(&self) -> bool {
167        matches!(self.owner, BufferOwner::Borrowed)
168    }
169
170    /// The device this allocation lives on (and whose EP must free it).
171    pub fn device(&self) -> DeviceId {
172        self.device
173    }
174
175    /// Allocation size in bytes.
176    pub fn len(&self) -> usize {
177        self.size
178    }
179
180    /// Whether the allocation is zero-length.
181    pub fn is_empty(&self) -> bool {
182        self.size == 0
183    }
184
185    /// Alignment (bytes) the base pointer was allocated to.
186    pub fn alignment(&self) -> usize {
187        self.align
188    }
189
190    /// Shared base pointer. Safe to obtain; dereferencing is `unsafe` and only
191    /// sound on host-accessible devices within the owning EP's context.
192    pub fn as_ptr(&self) -> *const c_void {
193        self.ptr.as_ptr()
194    }
195
196    /// Unique mutable base pointer. Requires `&mut self` so the borrow checker
197    /// forbids two writers sharing one buffer — this is what makes the `Sync`
198    /// impl sound (a shared `&DeviceBuffer` can never hand out a writable
199    /// pointer through safe code).
200    pub fn as_mut_ptr(&mut self) -> *mut c_void {
201        self.ptr.as_ptr()
202    }
203
204    /// Consume the handle, returning the raw pointer *without* freeing it. For
205    /// an owned buffer the caller assumes the single-free obligation from
206    /// [`DeviceBuffer::from_raw_parts`]. For a **borrowed** buffer (see
207    /// [`DeviceBuffer::from_borrowed_parts`]) the pointer must **not** be freed;
208    /// check [`is_borrowed`](DeviceBuffer::is_borrowed) first if the caller
209    /// intends to free.
210    pub fn into_raw(self) -> *mut c_void {
211        self.ptr.as_ptr()
212    }
213}
214
215// SAFETY: `DeviceBuffer` is an owning *handle* — it stores only a base address
216// plus metadata and exposes no safe way to read or write the pointed-to memory
217// (all access goes through `as_ptr`/`as_mut_ptr`, which are safe to *call* but
218// `unsafe` to *use*). Moving the handle to another thread transfers ownership of
219// the address; this is sound for every allocator we target — host `malloc`,
220// CUDA device pointers, and MLX unified memory are all address-portable and not
221// thread-affine at the pointer level. Any data race on the *contents* is
222// prevented one layer up by `&`/`&mut` aliasing on `TensorView`/`TensorMut` and
223// by the scheduler, not by this type. If a future EP wires a genuinely
224// thread-affine allocator, it must wrap the handle in a non-`Send` owner rather
225// than weaken this invariant (plan §4.4 flags this for a dedicated review when
226// ep-cpu lands real memory).
227unsafe impl Send for DeviceBuffer {}
228// SAFETY: `&DeviceBuffer` grants no interior mutability — it can only produce a
229// `*const` via `as_ptr` (a plain address copy) and read `Copy` metadata, so
230// concurrent shared reads of the handle are race-free. Writing requires
231// `as_mut_ptr`, which needs `&mut self`; obtaining a writable pointer therefore
232// cannot happen through a shared reference in safe code. As with `Send`,
233// mutating the underlying memory is gated behind `unsafe` pointer use whose
234// synchronization is the caller's responsibility.
235unsafe impl Sync for DeviceBuffer {}
236
237/// A synchronization fence returned by async operations.
238#[derive(Debug, Default)]
239pub struct Fence {
240    pub id: u64,
241}
242
243/// Marker for an EP exported as an ORT-compatible C ABI plugin (Phase 2).
244#[derive(Debug, Default)]
245pub struct OrtPluginExport {
246    pub register_symbol: String,
247}
248
249/// An EP-specific optimization pass.
250///
251/// Placeholder trait: the full pass pipeline lives in `onnx-runtime-optimizer`
252/// (Phase 2). Defined here so [`ExecutionProvider::custom_passes`] can name it
253/// without a Phase 2 crate dependency.
254pub trait OptimizerPass: Send + Sync {
255    fn name(&self) -> &str;
256}
257
258/// The core EP interface. Every backend crate implements this (§4.1).
259pub trait ExecutionProvider: Send + Sync {
260    /// EP identifier (snake_case, e.g. `"cpu_ep"`, `"cuda_ep"`).
261    fn name(&self) -> &str;
262
263    fn device_type(&self) -> DeviceType;
264    fn device_id(&self) -> DeviceId;
265
266    /// Initialize device resources / load libraries.
267    fn initialize(&mut self, config: &EpConfig) -> Result<()>;
268    /// Release device resources.
269    fn shutdown(&mut self) -> Result<()>;
270
271    /// Whether this EP can run `op` with the given input shapes and layouts,
272    /// and at what cost.
273    fn supports_op(&self, op: &Node, shapes: &[Shape], layouts: &[TensorLayout]) -> KernelMatch;
274
275    /// Get or create a kernel for `op` specialized to concrete `shapes`.
276    ///
277    /// `opset` is the effective operator-set version for `op`'s domain in the
278    /// owning graph. EPs use it to select opset-specialized kernels (e.g. the
279    /// opset-13 per-axis vs. the legacy opset-<13 2D-coercion `Softmax`).
280    fn get_kernel(&self, op: &Node, shapes: &[Vec<usize>], opset: u64)
281        -> Result<Box<dyn Kernel>>;
282
283    /// Allocate device memory.
284    fn allocate(&self, size: usize, alignment: usize) -> Result<DeviceBuffer>;
285    /// Free device memory.
286    fn deallocate(&self, buffer: DeviceBuffer) -> Result<()>;
287
288    /// Synchronous copy (host↔device or device↔device).
289    fn copy(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<()>;
290    /// Asynchronous copy; returns a [`Fence`] to await.
291    fn copy_async(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<Fence>;
292
293    /// Block until all pending work on this EP completes.
294    fn sync(&self) -> Result<()>;
295
296    /// Export this EP as an ORT C ABI plugin, if supported (Phase 2).
297    fn as_ort_plugin(&self) -> Option<OrtPluginExport> {
298        None
299    }
300
301    /// EP-specific optimization passes, run after the generic optimizer.
302    fn custom_passes(&self) -> Vec<Box<dyn OptimizerPass>> {
303        Vec::new()
304    }
305
306    /// Nodes this EP claims unconditionally (bypassing cost-model placement).
307    fn claim_nodes(&self, graph: &Graph) -> Vec<NodeId> {
308        let _ = graph;
309        Vec::new()
310    }
311
312    /// The `EPContext` node `source` key(s) this EP accepts for compiled-context
313    /// dispatch (`docs/ORT2.md` §55.6). The keys come from the EP's own
314    /// config/data — **never** hardcoded in loader/session dispatch. An empty
315    /// list (the default) means the EP does not participate in `EPContext`
316    /// (e.g. the pure-Rust CPU EP has no compile step).
317    fn context_source_keys(&self) -> Vec<String> {
318        Vec::new()
319    }
320
321    /// Produce the runtime [`EpContext`] for this EP's freshly compiled subgraph
322    /// (the §55.4 dump path calls this). Default: unsupported — an EP with no
323    /// compile step returns [`EpError::UnsupportedContext`].
324    fn save_context(&self) -> Result<EpContext> {
325        Err(EpError::UnsupportedContext {
326            ep: self.name().to_string(),
327        })
328    }
329
330    /// Restore this EP from a runtime [`EpContext`], skipping convert+compile
331    /// (the §55.3 load path calls this). Default: unsupported — an EP that does
332    /// not consume `EPContext` returns [`EpError::UnsupportedContext`].
333    fn load_context(&self, ctx: &EpContext) -> Result<()> {
334        let _ = ctx;
335        Err(EpError::UnsupportedContext {
336            ep: self.name().to_string(),
337        })
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn _assert_send_sync<T: Send + Sync>() {}
346
347    /// Leak a boxed byte slice as a stand-in host allocation.
348    fn host_alloc(size: usize, align: usize) -> DeviceBuffer {
349        let boxed = vec![0u8; size].into_boxed_slice();
350        let ptr = Box::into_raw(boxed) as *mut c_void;
351        // SAFETY: `ptr` is a valid, unique, non-null allocation of `size` bytes
352        // on the host, aligned to the allocator's guarantee (>= 1); we treat the
353        // CPU EP as its owner and free it exactly once in `host_free`.
354        unsafe { DeviceBuffer::from_raw_parts(ptr, DeviceId::cpu(), size, align) }
355    }
356
357    fn host_free(buf: DeviceBuffer) {
358        let size = buf.len();
359        let ptr = buf.into_raw() as *mut u8;
360        // SAFETY: reconstruct the exact `Box<[u8]>` leaked in `host_alloc` so it
361        // is freed once. `into_raw` consumed the handle, so no alias remains.
362        unsafe {
363            drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, size)));
364        }
365    }
366
367    #[test]
368    fn device_buffer_is_send_sync() {
369        _assert_send_sync::<DeviceBuffer>();
370    }
371
372    #[test]
373    fn buffer_metadata_and_single_free() {
374        let mut buf = host_alloc(128, 64);
375        assert_eq!(buf.len(), 128);
376        assert!(!buf.is_empty());
377        assert_eq!(buf.alignment(), 64);
378        assert_eq!(buf.device(), DeviceId::cpu());
379        assert!(!buf.as_ptr().is_null());
380        assert!(!buf.as_mut_ptr().is_null());
381        // Single free path — a double free here would trip ASan/Miri.
382        host_free(buf);
383    }
384
385    #[test]
386    fn buffer_moves_across_thread() {
387        let buf = host_alloc(64, 16);
388        let base = buf.as_ptr() as usize;
389        let handle = std::thread::spawn(move || {
390            assert_eq!(buf.len(), 64);
391            assert_eq!(buf.as_ptr() as usize, base);
392            buf // hand ownership back so the main thread frees it once
393        });
394        let buf = handle.join().unwrap();
395        host_free(buf);
396    }
397
398    #[test]
399    fn owned_buffer_is_not_borrowed() {
400        let buf = host_alloc(32, 16);
401        assert!(
402            !buf.is_borrowed(),
403            "from_raw_parts must produce an owned buffer"
404        );
405        host_free(buf);
406    }
407
408    /// A borrowed buffer aliases memory owned by someone else (here a `Vec`):
409    /// it reports `is_borrowed()`, exposes the aliased pointer, and consuming it
410    /// via `into_raw` must NOT free the backing — the `Vec` stays valid.
411    #[test]
412    fn borrowed_buffer_aliases_without_owning() {
413        let mut backing = vec![7u8; 64];
414        let ptr = backing.as_mut_ptr() as *mut c_void;
415        // SAFETY: `ptr`/`len` name `backing`'s live allocation (aligned to 1);
416        // `backing` outlives the buffer and every use below, and we never write
417        // through the borrowed handle.
418        let buf = unsafe { DeviceBuffer::from_borrowed_parts(ptr, DeviceId::cpu(), 64, 1) };
419        assert!(buf.is_borrowed());
420        assert_eq!(buf.len(), 64);
421        assert_eq!(buf.as_ptr(), ptr as *const c_void);
422        // Consume without freeing: `into_raw` must never free a borrowed buffer.
423        let raw = buf.into_raw();
424        assert_eq!(raw, ptr);
425        // `backing` is still fully valid — a free would be a use-after-free here.
426        assert!(backing.iter().all(|&b| b == 7));
427        backing[0] = 9;
428        assert_eq!(backing[0], 9);
429    }
430}