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}