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}