Skip to main content

onnx_runtime_session/
tensor.rs

1//! The owned, device-aware [`Tensor`] handed to and returned from
2//! [`InferenceSession::run`](crate::InferenceSession::run), plus the isolated
3//! host-buffer accessors it and the executor share.
4//!
5//! ## Placement decision (design open question, §20 / plan §2.D)
6//!
7//! The plan flagged *where* the real tensor type should live as an open
8//! question: keep it in `onnx-runtime-session`, or hoist it into a shared
9//! `onnx-runtime-tensor` crate. For Phase 1 (CPU only) it lives **here**: the
10//! type is a thin owner over an [`onnx_runtime_ep_api::DeviceBuffer`] plus the
11//! IR vocabulary ([`DataType`], [`TensorLayout`], shape) — nothing CPU-specific
12//! leaks into its shape. When `ep-cuda` lands and non-host tensors need DLPack
13//! import/export and cross-device copies, this is a mechanical move into a
14//! shared crate that both the session and the C-API can depend on; nothing in
15//! its public surface here presumes a host device beyond the accessors, which
16//! already gate on [`DeviceId::is_host_accessible`].
17//!
18//! ## The single `unsafe` seam
19//!
20//! A [`DeviceBuffer`] hands out only raw base pointers; reading or writing the
21//! bytes is `unsafe` and sound only on host-accessible devices. Every such
22//! access in this crate funnels through [`host_bytes`] / [`write_host`] /
23//! [`copy_host`], which assert host accessibility and length, so the rest of
24//! the crate — the executor and the public API — is safe Rust over the EP
25//! contract.
26
27use std::sync::{Arc, OnceLock};
28
29use onnx_runtime_ep_api::{DeviceBuffer, ExecutionProvider};
30use onnx_runtime_ep_cpu::CpuExecutionProvider;
31use onnx_runtime_ir::{DataType, DeviceId, TensorLayout};
32
33use crate::error::{Result, SessionError};
34
35/// A process-wide, already-initialized CPU execution provider used to back
36/// user-constructed [`Tensor`]s (host `malloc`/`free` is global, so any
37/// `CpuExecutionProvider` can free any other's CPU allocation).
38pub(crate) fn shared_cpu_ep() -> Arc<CpuExecutionProvider> {
39    static EP: OnceLock<Arc<CpuExecutionProvider>> = OnceLock::new();
40    EP.get_or_init(|| {
41        let mut ep = CpuExecutionProvider::new();
42        // Pure-Rust CPU EP: `initialize` only flips a flag and never fails.
43        let _ = ep.initialize(&Default::default());
44        Arc::new(ep)
45    })
46    .clone()
47}
48
49/// The shared CPU execution provider as an [`ExecutionProvider`] trait object.
50///
51/// Exposed so callers building a [`Tensor`] from *borrowed* host memory (e.g.
52/// the Python binding's zero-copy DLPack import) can supply the allocator
53/// [`Tensor::from_borrowed_parts_with_guard`] requires. Because a borrowed
54/// buffer is never actually freed by the EP, any CPU provider suffices.
55pub fn cpu_allocator() -> Arc<dyn ExecutionProvider> {
56    shared_cpu_ep()
57}
58
59/// Borrow the raw bytes of a host-accessible device buffer.
60///
61/// # Safety
62///
63/// `buffer` must live on a host-accessible device (asserted) and own a valid
64/// allocation of `buffer.len()` bytes (the EP contract for
65/// [`DeviceBuffer`]). The returned slice borrows `buffer`, so it cannot outlive
66/// it. No concurrent writer may exist for the borrow's duration — enforced by
67/// the `&DeviceBuffer` shared borrow in safe code.
68pub(crate) fn host_bytes(buffer: &DeviceBuffer) -> &[u8] {
69    assert!(
70        buffer.device().is_host_accessible(),
71        "host_bytes on non-host device {:?}",
72        buffer.device()
73    );
74    if buffer.is_empty() {
75        return &[];
76    }
77    // SAFETY: host-accessible device (asserted) means `as_ptr` is a real,
78    // readable host address; the EP guarantees `len()` valid bytes behind it.
79    // The lifetime is tied to `&buffer`, so the slice cannot dangle, and the
80    // shared borrow forbids an aliasing writer while it is live.
81    unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, buffer.len()) }
82}
83
84/// Copy `src` into the front of a host-accessible device buffer.
85pub(crate) fn write_host(buffer: &mut DeviceBuffer, src: &[u8]) -> Result<()> {
86    assert!(
87        buffer.device().is_host_accessible(),
88        "write_host on non-host device {:?}",
89        buffer.device()
90    );
91    if src.len() > buffer.len() {
92        return Err(SessionError::Internal(format!(
93            "write_host: source {} bytes exceeds buffer {} bytes",
94            src.len(),
95            buffer.len()
96        )));
97    }
98    if src.is_empty() {
99        return Ok(());
100    }
101    let dst = buffer.as_mut_ptr() as *mut u8;
102    // SAFETY: host-accessible device (asserted); `dst` is a unique writable host
103    // pointer obtained via `&mut buffer` (no alias), with at least `src.len()`
104    // bytes of capacity (checked above). `src` is a distinct owned slice, so the
105    // ranges do not overlap.
106    unsafe {
107        std::ptr::copy_nonoverlapping(src.as_ptr(), dst, src.len());
108    }
109    Ok(())
110}
111
112/// An owned, host-resident, device-aware tensor (§5, §20.2).
113///
114/// Owns the [`DeviceBuffer`] that holds its elements and the EP that must free
115/// it. On Phase-1 CPU the buffer is a host allocation, so [`Tensor::as_bytes`]
116/// and the typed accessors read it directly; the design leaves room for
117/// non-host devices (the accessors gate on host accessibility).
118pub struct Tensor {
119    /// Element type.
120    pub dtype: DataType,
121    /// Logical shape (static dims).
122    pub shape: Vec<usize>,
123    /// Physical layout of [`Tensor::buffer`]. Row-major contiguous for tensors
124    /// this crate produces.
125    pub layout: TensorLayout,
126    device: DeviceId,
127    /// `Some` while the tensor is live; taken by [`Drop`] to free exactly once.
128    buffer: Option<DeviceBuffer>,
129    /// The EP that allocated [`Tensor::buffer`] and must deallocate it.
130    allocator: Arc<dyn ExecutionProvider>,
131    /// Optional opaque guard that owns *foreign* memory this tensor merely
132    /// borrows (e.g. a DLPack `DLManagedTensor` imported zero-copy). It is
133    /// `None` for every tensor that owns its own allocation. When present,
134    /// [`Tensor::buffer`] is a **borrowed** [`DeviceBuffer`] aliasing memory the
135    /// guard is responsible for releasing; the guard's own `Drop` runs the
136    /// foreign deleter exactly once. [`Drop`] takes it **after** the buffer is
137    /// deallocated (a no-op for borrowed buffers) so the memory is never freed
138    /// while the buffer still aliases it. The concrete type lives in the caller
139    /// crate (the Python binding) — this crate only stores and drops it, so it
140    /// stays free of DLPack ABI knowledge.
141    import_guard: Option<Box<dyn core::any::Any + Send + Sync>>,
142}
143
144impl Tensor {
145    /// Allocate a tensor from raw little-endian element bytes using `allocator`.
146    ///
147    /// `bytes` must hold exactly `storage_bytes(numel)` bytes for `dtype` and
148    /// `shape`.
149    pub(crate) fn from_raw_in(
150        allocator: Arc<dyn ExecutionProvider>,
151        dtype: DataType,
152        shape: Vec<usize>,
153        bytes: &[u8],
154    ) -> Result<Self> {
155        let numel: usize = shape.iter().product();
156        let expected = dtype.storage_bytes(numel);
157        if bytes.len() != expected {
158            return Err(SessionError::Internal(format!(
159                "Tensor::from_raw_in: {} bytes for shape {shape:?} dtype {dtype:?}, expected {expected}",
160                bytes.len()
161            )));
162        }
163        let layout = TensorLayout::contiguous();
164        let align = layout.alignment;
165        let mut buffer = allocator.allocate(expected.max(1), align)?;
166        write_host(&mut buffer, bytes)?;
167        Ok(Self {
168            dtype,
169            shape,
170            layout,
171            device: buffer.device(),
172            buffer: Some(buffer),
173            allocator,
174            import_guard: None,
175        })
176    }
177
178    /// Build a tensor from raw little-endian bytes on the shared CPU device.
179    pub fn from_raw(dtype: DataType, shape: Vec<usize>, bytes: &[u8]) -> Result<Self> {
180        Self::from_raw_in(shared_cpu_ep(), dtype, shape, bytes)
181    }
182
183    /// Build an `f32` tensor from a dense row-major slice.
184    pub fn from_f32(shape: &[usize], data: &[f32]) -> Result<Self> {
185        let mut bytes = Vec::with_capacity(data.len() * 4);
186        for v in data {
187            bytes.extend_from_slice(&v.to_le_bytes());
188        }
189        Self::from_raw(DataType::Float32, shape.to_vec(), &bytes)
190    }
191
192    /// Build an `i64` tensor from a dense row-major slice.
193    pub fn from_i64(shape: &[usize], data: &[i64]) -> Result<Self> {
194        let mut bytes = Vec::with_capacity(data.len() * 8);
195        for v in data {
196            bytes.extend_from_slice(&v.to_le_bytes());
197        }
198        Self::from_raw(DataType::Int64, shape.to_vec(), &bytes)
199    }
200
201    /// The device this tensor lives on.
202    pub fn device(&self) -> DeviceId {
203        self.device
204    }
205
206    /// Wrap **foreign, borrowed** memory in a `Tensor`, with an opaque `guard`
207    /// that releases the foreign allocation when the tensor is dropped.
208    ///
209    /// This is the zero-copy *import* constructor: `buffer` must be a
210    /// **borrowed** [`DeviceBuffer`] (built via
211    /// [`DeviceBuffer::from_borrowed_parts`]) aliasing memory owned by whatever
212    /// `guard` boxes up — for a DLPack import, `guard` owns the foreign
213    /// `DLManagedTensor` and its `Drop` calls that tensor's `deleter` exactly
214    /// once. Because the buffer is borrowed, the owning EP's `deallocate` is a
215    /// no-op for it, so the *only* thing that frees the aliased memory is the
216    /// guard.
217    ///
218    /// # Ordering invariant
219    ///
220    /// [`Drop`] deallocates `buffer` (a no-op for a borrowed buffer) and only
221    /// **then** drops the guard, so the guard's deleter never runs while the
222    /// buffer still aliases the foreign memory. Do not rely on the guard freeing
223    /// anything the buffer still points at before `drop` completes.
224    ///
225    /// # Panics
226    ///
227    /// Panics (debug builds) if `buffer` is not borrowed — an owned buffer here
228    /// would be double-freed (once by the EP, once by the guard).
229    pub fn from_borrowed_parts_with_guard(
230        allocator: Arc<dyn ExecutionProvider>,
231        dtype: DataType,
232        shape: Vec<usize>,
233        layout: TensorLayout,
234        buffer: DeviceBuffer,
235        guard: Box<dyn core::any::Any + Send + Sync>,
236    ) -> Self {
237        debug_assert!(
238            buffer.is_borrowed(),
239            "from_borrowed_parts_with_guard requires a borrowed DeviceBuffer; \
240             an owned buffer would be freed twice (EP deallocate + guard)"
241        );
242        Self {
243            dtype,
244            shape,
245            layout,
246            device: buffer.device(),
247            buffer: Some(buffer),
248            allocator,
249            import_guard: Some(guard),
250        }
251    }
252
253    /// Number of elements.
254    pub fn numel(&self) -> usize {
255        self.shape.iter().product()
256    }
257
258    /// Base pointer of this tensor's backing allocation.
259    ///
260    /// For host-accessible devices (CPU, MLX) this is a dereferenceable host
261    /// pointer; for device memory (CUDA/ROCm) it is an **opaque device address**
262    /// only meaningful inside the owning EP's context — never dereference it on
263    /// the host. This is the device-agnostic base the zero-copy DLPack **export**
264    /// path hands to a consumer, so a CUDA-resident output can be borrowed as a
265    /// `kDLCUDA` tensor without a host round-trip. Returns null for an empty
266    /// (zero-element) tensor.
267    pub fn device_ptr(&self) -> *const std::ffi::c_void {
268        if self.numel() == 0 {
269            std::ptr::null()
270        } else {
271            self.buffer().as_ptr()
272        }
273    }
274
275    /// Block until all pending work on the owning EP's stream completes.
276    ///
277    /// Device-agnostic: the CPU EP's `sync` is a no-op, while the CUDA EP fully
278    /// synchronizes its compute stream. The DLPack **export** path calls this
279    /// before handing a `kDLCUDA` buffer to a foreign consumer, so the producer's
280    /// device work is guaranteed complete (and thus the data valid) regardless of
281    /// which stream the consumer reads on — the conservative, always-correct end
282    /// of the DLPack stream handshake.
283    pub fn sync(&self) -> Result<()> {
284        self.allocator.sync()?;
285        Ok(())
286    }
287
288    fn buffer(&self) -> &DeviceBuffer {
289        self.buffer
290            .as_ref()
291            .expect("Tensor buffer taken only in Drop")
292    }
293
294    /// Borrow the raw little-endian element bytes (host tensors only).
295    pub fn as_bytes(&self) -> &[u8] {
296        let n = self.dtype.storage_bytes(self.numel());
297        &host_bytes(self.buffer())[..n]
298    }
299
300    /// Replace this tensor's logical bytes without reallocating its backing
301    /// buffer. Used by control-flow iteration inputs whose dtype/shape stay
302    /// constant while their values change.
303    pub(crate) fn overwrite_bytes(&mut self, bytes: &[u8]) -> Result<()> {
304        let expected = self.dtype.storage_bytes(self.numel());
305        if bytes.len() != expected {
306            return Err(SessionError::Internal(format!(
307                "Tensor::overwrite_bytes: got {} bytes for shape {:?} dtype {:?}, expected {expected}",
308                bytes.len(), self.shape, self.dtype
309            )));
310        }
311        let buffer = self.buffer.as_mut().expect("Tensor buffer taken only in Drop");
312        write_host(buffer, bytes)
313    }
314
315    /// Copy out the elements as `f32`. Panics if the dtype is not `Float32`.
316    pub fn to_vec_f32(&self) -> Vec<f32> {
317        assert_eq!(self.dtype, DataType::Float32, "to_vec_f32 on non-f32 tensor");
318        self.as_bytes()
319            .chunks_exact(4)
320            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
321            .collect()
322    }
323
324    /// Copy out the elements as `i64`. Panics if the dtype is not `Int64`.
325    pub fn to_vec_i64(&self) -> Vec<i64> {
326        assert_eq!(self.dtype, DataType::Int64, "to_vec_i64 on non-i64 tensor");
327        self.as_bytes()
328            .chunks_exact(8)
329            .map(|c| i64::from_le_bytes(c.try_into().unwrap()))
330            .collect()
331    }
332}
333
334impl Clone for Tensor {
335    fn clone(&self) -> Self {
336        // Deep copy: a fresh allocation with identical bytes. Cannot fail for
337        // host allocations of the same size; propagate as a panic-free fallback
338        // by re-using `from_raw_in` and unwrapping the size-checked path.
339        Self::from_raw_in(
340            self.allocator.clone(),
341            self.dtype,
342            self.shape.clone(),
343            self.as_bytes(),
344        )
345        .expect("Tensor::clone: re-allocation of identical bytes")
346    }
347}
348
349impl std::fmt::Debug for Tensor {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        f.debug_struct("Tensor")
352            .field("dtype", &self.dtype)
353            .field("shape", &self.shape)
354            .field("device", &self.device)
355            .finish()
356    }
357}
358
359impl Drop for Tensor {
360    fn drop(&mut self) {
361        if let Some(buffer) = self.buffer.take() {
362            // `DeviceBuffer` has no `Drop`; the owning EP must free it exactly
363            // once (ep-api §4.4 invariant #2). Errors here cannot be surfaced
364            // from `drop`, so we swallow them — a failed free leaks, never
365            // double-frees.
366            let _ = self.allocator.deallocate(buffer);
367        }
368        // Release any foreign (DLPack-imported) allocation *after* the buffer
369        // aliasing it has been handed back to the EP. For a borrowed buffer the
370        // `deallocate` above is a no-op, so this guard's `Drop` (which runs the
371        // foreign deleter) is the sole owner that frees the memory — and it must
372        // run last, once the buffer no longer aliases it. `None` for tensors
373        // that own their allocation.
374        let _ = self.import_guard.take();
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use std::os::raw::c_void;
382    use std::sync::atomic::{AtomicUsize, Ordering};
383
384    /// A guard whose `Drop` bumps a shared counter — stands in for the DLPack
385    /// deleter the Python binding boxes into an imported tensor.
386    struct CountingGuard(Arc<AtomicUsize>);
387    impl Drop for CountingGuard {
388        fn drop(&mut self) {
389            self.0.fetch_add(1, Ordering::SeqCst);
390        }
391    }
392
393    #[test]
394    fn borrowed_guard_ctor_runs_guard_exactly_once_on_drop() {
395        let drops = Arc::new(AtomicUsize::new(0));
396        // Some real host memory the borrowed buffer can alias.
397        let mut backing = [1.0f32, 2.0, 3.0, 4.0];
398        let ptr = backing.as_mut_ptr() as *mut c_void;
399        // SAFETY: `backing` outlives the tensor built below; 16 bytes, 4-aligned.
400        let buffer = unsafe {
401            DeviceBuffer::from_borrowed_parts(ptr, DeviceId::cpu(), backing.len() * 4, 4)
402        };
403        assert!(buffer.is_borrowed());
404
405        let guard = Box::new(CountingGuard(drops.clone()));
406        let tensor = Tensor::from_borrowed_parts_with_guard(
407            shared_cpu_ep(),
408            DataType::Float32,
409            vec![4],
410            TensorLayout::contiguous(),
411            buffer,
412            guard,
413        );
414
415        // The tensor aliases the backing store without copying it.
416        assert_eq!(tensor.as_bytes().len(), 16);
417        assert_eq!(tensor.to_vec_f32(), vec![1.0, 2.0, 3.0, 4.0]);
418        assert_eq!(drops.load(Ordering::SeqCst), 0, "guard alive while tensor is");
419
420        drop(tensor);
421        assert_eq!(drops.load(Ordering::SeqCst), 1, "guard runs exactly once on drop");
422    }
423}