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}