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/// Borrow the raw bytes of a host-accessible device buffer.
50///
51/// # Safety
52///
53/// `buffer` must live on a host-accessible device (asserted) and own a valid
54/// allocation of `buffer.len()` bytes (the EP contract for
55/// [`DeviceBuffer`]). The returned slice borrows `buffer`, so it cannot outlive
56/// it. No concurrent writer may exist for the borrow's duration — enforced by
57/// the `&DeviceBuffer` shared borrow in safe code.
58pub(crate) fn host_bytes(buffer: &DeviceBuffer) -> &[u8] {
59 assert!(
60 buffer.device().is_host_accessible(),
61 "host_bytes on non-host device {:?}",
62 buffer.device()
63 );
64 if buffer.is_empty() {
65 return &[];
66 }
67 // SAFETY: host-accessible device (asserted) means `as_ptr` is a real,
68 // readable host address; the EP guarantees `len()` valid bytes behind it.
69 // The lifetime is tied to `&buffer`, so the slice cannot dangle, and the
70 // shared borrow forbids an aliasing writer while it is live.
71 unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, buffer.len()) }
72}
73
74/// Copy `src` into the front of a host-accessible device buffer.
75pub(crate) fn write_host(buffer: &mut DeviceBuffer, src: &[u8]) -> Result<()> {
76 assert!(
77 buffer.device().is_host_accessible(),
78 "write_host on non-host device {:?}",
79 buffer.device()
80 );
81 if src.len() > buffer.len() {
82 return Err(SessionError::Internal(format!(
83 "write_host: source {} bytes exceeds buffer {} bytes",
84 src.len(),
85 buffer.len()
86 )));
87 }
88 if src.is_empty() {
89 return Ok(());
90 }
91 let dst = buffer.as_mut_ptr() as *mut u8;
92 // SAFETY: host-accessible device (asserted); `dst` is a unique writable host
93 // pointer obtained via `&mut buffer` (no alias), with at least `src.len()`
94 // bytes of capacity (checked above). `src` is a distinct owned slice, so the
95 // ranges do not overlap.
96 unsafe {
97 std::ptr::copy_nonoverlapping(src.as_ptr(), dst, src.len());
98 }
99 Ok(())
100}
101
102/// An owned, host-resident, device-aware tensor (§5, §20.2).
103///
104/// Owns the [`DeviceBuffer`] that holds its elements and the EP that must free
105/// it. On Phase-1 CPU the buffer is a host allocation, so [`Tensor::as_bytes`]
106/// and the typed accessors read it directly; the design leaves room for
107/// non-host devices (the accessors gate on host accessibility).
108pub struct Tensor {
109 /// Element type.
110 pub dtype: DataType,
111 /// Logical shape (static dims).
112 pub shape: Vec<usize>,
113 /// Physical layout of [`Tensor::buffer`]. Row-major contiguous for tensors
114 /// this crate produces.
115 pub layout: TensorLayout,
116 device: DeviceId,
117 /// `Some` while the tensor is live; taken by [`Drop`] to free exactly once.
118 buffer: Option<DeviceBuffer>,
119 /// The EP that allocated [`Tensor::buffer`] and must deallocate it.
120 allocator: Arc<dyn ExecutionProvider>,
121}
122
123impl Tensor {
124 /// Allocate a tensor from raw little-endian element bytes using `allocator`.
125 ///
126 /// `bytes` must hold exactly `storage_bytes(numel)` bytes for `dtype` and
127 /// `shape`.
128 pub(crate) fn from_raw_in(
129 allocator: Arc<dyn ExecutionProvider>,
130 dtype: DataType,
131 shape: Vec<usize>,
132 bytes: &[u8],
133 ) -> Result<Self> {
134 let numel: usize = shape.iter().product();
135 let expected = dtype.storage_bytes(numel);
136 if bytes.len() != expected {
137 return Err(SessionError::Internal(format!(
138 "Tensor::from_raw_in: {} bytes for shape {shape:?} dtype {dtype:?}, expected {expected}",
139 bytes.len()
140 )));
141 }
142 let layout = TensorLayout::contiguous();
143 let align = layout.alignment;
144 let mut buffer = allocator.allocate(expected.max(1), align)?;
145 write_host(&mut buffer, bytes)?;
146 Ok(Self {
147 dtype,
148 shape,
149 layout,
150 device: buffer.device(),
151 buffer: Some(buffer),
152 allocator,
153 })
154 }
155
156 /// Build a tensor from raw little-endian bytes on the shared CPU device.
157 pub fn from_raw(dtype: DataType, shape: Vec<usize>, bytes: &[u8]) -> Result<Self> {
158 Self::from_raw_in(shared_cpu_ep(), dtype, shape, bytes)
159 }
160
161 /// Build an `f32` tensor from a dense row-major slice.
162 pub fn from_f32(shape: &[usize], data: &[f32]) -> Result<Self> {
163 let mut bytes = Vec::with_capacity(data.len() * 4);
164 for v in data {
165 bytes.extend_from_slice(&v.to_le_bytes());
166 }
167 Self::from_raw(DataType::Float32, shape.to_vec(), &bytes)
168 }
169
170 /// Build an `i64` tensor from a dense row-major slice.
171 pub fn from_i64(shape: &[usize], data: &[i64]) -> Result<Self> {
172 let mut bytes = Vec::with_capacity(data.len() * 8);
173 for v in data {
174 bytes.extend_from_slice(&v.to_le_bytes());
175 }
176 Self::from_raw(DataType::Int64, shape.to_vec(), &bytes)
177 }
178
179 /// The device this tensor lives on.
180 pub fn device(&self) -> DeviceId {
181 self.device
182 }
183
184 /// Number of elements.
185 pub fn numel(&self) -> usize {
186 self.shape.iter().product()
187 }
188
189 fn buffer(&self) -> &DeviceBuffer {
190 self.buffer
191 .as_ref()
192 .expect("Tensor buffer taken only in Drop")
193 }
194
195 /// Borrow the raw little-endian element bytes (host tensors only).
196 pub fn as_bytes(&self) -> &[u8] {
197 let n = self.dtype.storage_bytes(self.numel());
198 &host_bytes(self.buffer())[..n]
199 }
200
201 /// Copy out the elements as `f32`. Panics if the dtype is not `Float32`.
202 pub fn to_vec_f32(&self) -> Vec<f32> {
203 assert_eq!(self.dtype, DataType::Float32, "to_vec_f32 on non-f32 tensor");
204 self.as_bytes()
205 .chunks_exact(4)
206 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
207 .collect()
208 }
209
210 /// Copy out the elements as `i64`. Panics if the dtype is not `Int64`.
211 pub fn to_vec_i64(&self) -> Vec<i64> {
212 assert_eq!(self.dtype, DataType::Int64, "to_vec_i64 on non-i64 tensor");
213 self.as_bytes()
214 .chunks_exact(8)
215 .map(|c| i64::from_le_bytes(c.try_into().unwrap()))
216 .collect()
217 }
218}
219
220impl Clone for Tensor {
221 fn clone(&self) -> Self {
222 // Deep copy: a fresh allocation with identical bytes. Cannot fail for
223 // host allocations of the same size; propagate as a panic-free fallback
224 // by re-using `from_raw_in` and unwrapping the size-checked path.
225 Self::from_raw_in(
226 self.allocator.clone(),
227 self.dtype,
228 self.shape.clone(),
229 self.as_bytes(),
230 )
231 .expect("Tensor::clone: re-allocation of identical bytes")
232 }
233}
234
235impl std::fmt::Debug for Tensor {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.debug_struct("Tensor")
238 .field("dtype", &self.dtype)
239 .field("shape", &self.shape)
240 .field("device", &self.device)
241 .finish()
242 }
243}
244
245impl Drop for Tensor {
246 fn drop(&mut self) {
247 if let Some(buffer) = self.buffer.take() {
248 // `DeviceBuffer` has no `Drop`; the owning EP must free it exactly
249 // once (ep-api §4.4 invariant #2). Errors here cannot be surfaced
250 // from `drop`, so we swallow them — a failed free leaks, never
251 // double-frees.
252 let _ = self.allocator.deallocate(buffer);
253 }
254 }
255}