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}