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 direct
22//! host read in this crate funnels through [`host_bytes`], while writes use the
23//! owning execution provider's host-copy API, so the rest of the crate — the
24//! executor and the public API — is safe Rust over the EP contract.
25
26use std::sync::{Arc, OnceLock};
27
28use onnx_runtime_ep_api::{DeviceBuffer, ExecutionProvider};
29use onnx_runtime_ep_cpu::CpuExecutionProvider;
30use onnx_runtime_ir::{DataType, DeviceId, TensorLayout};
31
32use crate::error::{Result, SessionError};
33
34/// A process-wide, already-initialized CPU execution provider used to back
35/// user-constructed [`Tensor`]s (host `malloc`/`free` is global, so any
36/// `CpuExecutionProvider` can free any other's CPU allocation).
37pub(crate) fn shared_cpu_ep() -> Arc<CpuExecutionProvider> {
38    static EP: OnceLock<Arc<CpuExecutionProvider>> = OnceLock::new();
39    EP.get_or_init(|| {
40        let mut ep = CpuExecutionProvider::new();
41        // Pure-Rust CPU EP: `initialize` only flips a flag and never fails.
42        let _ = ep.initialize(&Default::default());
43        Arc::new(ep)
44    })
45    .clone()
46}
47
48/// The shared CPU execution provider as an [`ExecutionProvider`] trait object.
49///
50/// Exposed so callers building a [`Tensor`] from *borrowed* host memory (e.g.
51/// the Python binding's zero-copy DLPack import) can supply the allocator
52/// [`Tensor::from_borrowed_parts_with_guard`] requires. Because a borrowed
53/// buffer is never actually freed by the EP, any CPU provider suffices.
54pub fn cpu_allocator() -> Arc<dyn ExecutionProvider> {
55    shared_cpu_ep()
56}
57
58/// Ref-counted owner for one device allocation shared by immutable runtime
59/// tensor values such as ONNX Sequence elements.
60///
61/// The executor may keep a non-owning [`DeviceBuffer`] alias for ordinary
62/// kernel dispatch while sequence handles retain this owner. The allocation is
63/// released exactly once when the last owner drops.
64pub(crate) struct SharedTensorBuffer {
65    buffer: Option<DeviceBuffer>,
66    allocator: Arc<dyn ExecutionProvider>,
67    import_guard: Option<Box<dyn core::any::Any + Send + Sync>>,
68}
69
70impl SharedTensorBuffer {
71    pub(crate) fn new(allocator: Arc<dyn ExecutionProvider>, buffer: DeviceBuffer) -> Arc<Self> {
72        Arc::new(Self {
73            buffer: Some(buffer),
74            allocator,
75            import_guard: None,
76        })
77    }
78
79    fn with_guard(
80        allocator: Arc<dyn ExecutionProvider>,
81        buffer: DeviceBuffer,
82        import_guard: Option<Box<dyn core::any::Any + Send + Sync>>,
83    ) -> Arc<Self> {
84        Arc::new(Self {
85            buffer: Some(buffer),
86            allocator,
87            import_guard,
88        })
89    }
90
91    pub(crate) fn allocate_cpu(bytes: usize) -> Result<Arc<Self>> {
92        let allocator: Arc<dyn ExecutionProvider> = shared_cpu_ep();
93        let buffer = allocator.allocate(bytes.max(1), TensorLayout::contiguous().alignment)?;
94        Ok(Self::new(allocator, buffer))
95    }
96
97    pub(crate) fn buffer(&self) -> &DeviceBuffer {
98        self.buffer
99            .as_ref()
100            .expect("SharedTensorBuffer buffer taken only in Drop")
101    }
102
103    pub(crate) fn buffer_mut(&mut self) -> &mut DeviceBuffer {
104        self.buffer
105            .as_mut()
106            .expect("SharedTensorBuffer buffer taken only in Drop")
107    }
108
109    pub(crate) fn allocator(&self) -> &Arc<dyn ExecutionProvider> {
110        &self.allocator
111    }
112
113    /// Create a non-owning alias suitable for the executor's existing
114    /// `DeviceBuffer` dispatch path. The returned handle must not outlive `self`.
115    pub(crate) fn alias(&self) -> DeviceBuffer {
116        let buffer = self.buffer();
117        // SAFETY: `self` owns the allocation and the executor keeps an Arc<Self>
118        // alive for at least as long as this alias. The alias is never freed by
119        // the EP because it is marked borrowed.
120        unsafe {
121            DeviceBuffer::from_borrowed_parts(
122                buffer.as_ptr() as *mut std::ffi::c_void,
123                buffer.device(),
124                buffer.len(),
125                buffer.alignment(),
126            )
127        }
128    }
129
130    pub(crate) fn into_buffer(mut self) -> DeviceBuffer {
131        debug_assert!(
132            self.import_guard.is_none(),
133            "executor-promoted buffers never carry a foreign import guard"
134        );
135        self.buffer
136            .take()
137            .expect("SharedTensorBuffer buffer taken only by into_buffer or Drop")
138    }
139}
140
141impl std::fmt::Debug for SharedTensorBuffer {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("SharedTensorBuffer")
144            .field("device", &self.buffer().device())
145            .field("len", &self.buffer().len())
146            .field("ptr", &self.buffer().as_ptr())
147            .finish()
148    }
149}
150
151impl Drop for SharedTensorBuffer {
152    fn drop(&mut self) {
153        if let Some(buffer) = self.buffer.take() {
154            let _ = self.allocator.deallocate(buffer);
155        }
156        let _ = self.import_guard.take();
157    }
158}
159
160/// Borrow the raw bytes of a host-accessible device buffer.
161///
162/// # Safety
163///
164/// `buffer` must live on a host-accessible device (asserted) and own a valid
165/// allocation of `buffer.len()` bytes (the EP contract for
166/// [`DeviceBuffer`]). The returned slice borrows `buffer`, so it cannot outlive
167/// it. No concurrent writer may exist for the borrow's duration — enforced by
168/// the `&DeviceBuffer` shared borrow in safe code.
169pub(crate) fn host_bytes(buffer: &DeviceBuffer) -> &[u8] {
170    assert!(
171        buffer.device().is_host_accessible(),
172        "host_bytes on non-host device {:?}",
173        buffer.device()
174    );
175    if buffer.is_empty() {
176        return &[];
177    }
178    // SAFETY: host-accessible device (asserted) means `as_ptr` is a real,
179    // readable host address; the EP guarantees `len()` valid bytes behind it.
180    // The lifetime is tied to `&buffer`, so the slice cannot dangle, and the
181    // shared borrow forbids an aliasing writer while it is live.
182    unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, buffer.len()) }
183}
184
185/// An owned, host-resident, device-aware tensor (§5, §20.2).
186///
187/// Owns the [`DeviceBuffer`] that holds its elements and the EP that must free
188/// it. On Phase-1 CPU the buffer is a host allocation, so [`Tensor::as_bytes`]
189/// and the typed accessors read it directly; the design leaves room for
190/// non-host devices (the accessors gate on host accessibility).
191pub struct Tensor {
192    /// Element type.
193    pub dtype: DataType,
194    /// Logical shape (static dims).
195    pub shape: Vec<usize>,
196    /// Physical layout of [`Tensor::buffer`]. Row-major contiguous for tensors
197    /// this crate produces.
198    pub layout: TensorLayout,
199    device: DeviceId,
200    /// `Some` while the tensor is live; taken by [`Drop`] to free exactly once.
201    buffer: Option<DeviceBuffer>,
202    /// The EP that allocated [`Tensor::buffer`] and must deallocate it.
203    allocator: Arc<dyn ExecutionProvider>,
204    /// Optional opaque guard that owns *foreign* memory this tensor merely
205    /// borrows (e.g. a DLPack `DLManagedTensor` imported zero-copy). It is
206    /// `None` for every tensor that owns its own allocation. When present,
207    /// [`Tensor::buffer`] is a **borrowed** [`DeviceBuffer`] aliasing memory the
208    /// guard is responsible for releasing; the guard's own `Drop` runs the
209    /// foreign deleter exactly once. [`Drop`] takes it **after** the buffer is
210    /// deallocated (a no-op for borrowed buffers) so the memory is never freed
211    /// while the buffer still aliases it. The concrete type lives in the caller
212    /// crate (the Python binding) — this crate only stores and drops it, so it
213    /// stays free of DLPack ABI knowledge.
214    import_guard: Option<Box<dyn core::any::Any + Send + Sync>>,
215}
216
217/// Debug counters for host traffic explicitly requested through a persistent
218/// device binding.
219#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
220pub struct DeviceBindingTransferStats {
221    pub host_upload_calls: u64,
222    pub host_upload_bytes: u64,
223    pub host_download_calls: u64,
224    pub host_download_bytes: u64,
225}
226
227/// An externally owned persistent device allocation bound to a graph input and
228/// optionally aliased by a graph output.
229pub struct DeviceIoBinding {
230    input_name: String,
231    bind_input: bool,
232    output_name: Option<String>,
233    pub dtype: DataType,
234    physical_shape: Vec<usize>,
235    logical_shape: Vec<usize>,
236    /// Whether graph inputs see the valid prefix rather than allocation capacity.
237    expose_logical_input_shape: bool,
238    buffer: Option<DeviceBuffer>,
239    allocator: Arc<dyn ExecutionProvider>,
240    transfer_stats: DeviceBindingTransferStats,
241}
242
243impl DeviceIoBinding {
244    pub(crate) fn allocate(
245        allocator: Arc<dyn ExecutionProvider>,
246        input_name: String,
247        bind_input: bool,
248        output_name: Option<String>,
249        dtype: DataType,
250        physical_shape: Vec<usize>,
251        logical_shape: Vec<usize>,
252        expose_logical_input_shape: bool,
253    ) -> Result<Self> {
254        validate_logical_shape(&physical_shape, &logical_shape)?;
255        let numel = physical_shape.iter().try_fold(1usize, |product, &dim| {
256            product.checked_mul(dim).ok_or_else(|| {
257                SessionError::Internal(format!(
258                    "device binding '{input_name}' physical shape overflows: {physical_shape:?}"
259                ))
260            })
261        })?;
262        let bytes = dtype.storage_bytes(numel).max(1);
263        let allocator_for_buffer = allocator.clone();
264        let buffer = allocator_for_buffer.allocate(bytes, TensorLayout::contiguous().alignment)?;
265        Ok(Self {
266            input_name,
267            bind_input,
268            output_name,
269            dtype,
270            physical_shape,
271            logical_shape,
272            expose_logical_input_shape,
273            buffer: Some(buffer),
274            allocator,
275            transfer_stats: DeviceBindingTransferStats::default(),
276        })
277    }
278
279    pub fn input_name(&self) -> &str {
280        &self.input_name
281    }
282
283    pub(crate) fn binds_input(&self) -> bool {
284        self.bind_input
285    }
286
287    pub fn output_name(&self) -> Option<&str> {
288        self.output_name.as_deref()
289    }
290
291    pub fn physical_shape(&self) -> &[usize] {
292        &self.physical_shape
293    }
294
295    pub fn logical_shape(&self) -> &[usize] {
296        &self.logical_shape
297    }
298
299    pub(crate) fn kernel_input_shape(&self) -> &[usize] {
300        if self.expose_logical_input_shape {
301            &self.logical_shape
302        } else {
303            &self.physical_shape
304        }
305    }
306
307    /// Whether kernels see a logical prefix whose shape can differ from capacity.
308    pub fn has_dynamic_logical_input_shape(&self) -> bool {
309        self.bind_input
310            && self.expose_logical_input_shape
311            && self.logical_shape != self.physical_shape
312    }
313
314    /// Whether this input binding is configured to expose its *logical* prefix
315    /// (valid length) to kernels rather than the padded physical capacity. Unlike
316    /// [`Self::has_dynamic_logical_input_shape`], this is a *static* property of
317    /// the binding (fixed at allocation from the consumer-scoped capacity policy)
318    /// and does not depend on the current logical shape. Callers that freeze a
319    /// growing input to physical capacity for CUDA-graph eligibility must consult
320    /// this: a binding that exposes its logical prefix cannot be frozen, because
321    /// at least one consumer requires the valid length rather than the padded
322    /// capacity (e.g. GLM-5.2's indexer arithmetic branch).
323    pub fn exposes_logical_input_shape(&self) -> bool {
324        self.bind_input && self.expose_logical_input_shape
325    }
326
327    pub fn set_logical_shape(&mut self, shape: Vec<usize>) -> Result<()> {
328        validate_logical_shape(&self.physical_shape, &shape)?;
329        self.logical_shape = shape;
330        Ok(())
331    }
332
333    pub fn device_ptr(&self) -> *const std::ffi::c_void {
334        self.buffer().as_ptr()
335    }
336
337    pub fn transfer_stats(&self) -> DeviceBindingTransferStats {
338        self.transfer_stats
339    }
340
341    pub fn write_bytes(&mut self, byte_offset: usize, bytes: &[u8]) -> Result<()> {
342        let buffer = self
343            .buffer
344            .as_mut()
345            .expect("DeviceIoBinding buffer taken only in Drop");
346        self.allocator
347            .copy_from_host_at(bytes, buffer, byte_offset)?;
348        self.transfer_stats.host_upload_calls += 1;
349        self.transfer_stats.host_upload_bytes += bytes.len() as u64;
350        Ok(())
351    }
352
353    pub fn read_bytes(&mut self) -> Result<Vec<u8>> {
354        let mut bytes = vec![0; self.buffer().len()];
355        self.read_bytes_into(&mut bytes)?;
356        Ok(bytes)
357    }
358
359    pub fn read_bytes_into(&mut self, bytes: &mut [u8]) -> Result<()> {
360        self.allocator.copy_to_host(self.buffer(), bytes)?;
361        self.transfer_stats.host_download_calls += 1;
362        self.transfer_stats.host_download_bytes += bytes.len() as u64;
363        Ok(())
364    }
365
366    pub fn device_argmax_supported(&self) -> bool {
367        self.allocator.device_argmax_supported()
368    }
369
370    pub fn device_argmax(&self, elements: usize, result: &mut DeviceIoBinding) -> Result<()> {
371        if !matches!(self.dtype, DataType::Float32 | DataType::Float16)
372            || result.dtype != DataType::Uint32
373        {
374            return Err(SessionError::Internal(format!(
375                "device argmax requires f32/f16 logits and u32 result, got {:?} and {:?}",
376                self.dtype, result.dtype
377            )));
378        }
379        if !Arc::ptr_eq(&self.allocator, &result.allocator) {
380            return Err(SessionError::Internal(
381                "device argmax bindings must belong to the same execution provider".into(),
382            ));
383        }
384        Ok(self.allocator.device_argmax(
385            self.buffer(),
386            elements,
387            self.dtype,
388            result.buffer_mut(),
389        )?)
390    }
391
392    pub(crate) fn buffer(&self) -> &DeviceBuffer {
393        self.buffer
394            .as_ref()
395            .expect("DeviceIoBinding buffer taken only in Drop")
396    }
397
398    pub(crate) fn buffer_mut(&mut self) -> &mut DeviceBuffer {
399        self.buffer
400            .as_mut()
401            .expect("DeviceIoBinding buffer taken only in Drop")
402    }
403}
404
405fn validate_logical_shape(physical: &[usize], logical: &[usize]) -> Result<()> {
406    if physical.len() != logical.len()
407        || physical
408            .iter()
409            .zip(logical)
410            .any(|(&capacity, &valid)| valid > capacity)
411    {
412        return Err(SessionError::Internal(format!(
413            "device binding logical shape {logical:?} exceeds physical capacity {physical:?}"
414        )));
415    }
416    Ok(())
417}
418
419impl std::fmt::Debug for DeviceIoBinding {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        f.debug_struct("DeviceIoBinding")
422            .field("input_name", &self.input_name)
423            .field("bind_input", &self.bind_input)
424            .field("output_name", &self.output_name)
425            .field("dtype", &self.dtype)
426            .field("physical_shape", &self.physical_shape)
427            .field("logical_shape", &self.logical_shape)
428            .field(
429                "expose_logical_input_shape",
430                &self.expose_logical_input_shape,
431            )
432            .field("device", &self.buffer().device())
433            .field("device_ptr", &self.device_ptr())
434            .field("transfer_stats", &self.transfer_stats)
435            .finish()
436    }
437}
438
439impl Drop for DeviceIoBinding {
440    fn drop(&mut self) {
441        if let Some(buffer) = self.buffer.take() {
442            let _ = self.allocator.reset_device_graph();
443            let _ = self.allocator.deallocate(buffer);
444        }
445    }
446}
447
448impl Tensor {
449    pub(crate) fn allocate_cpu(dtype: DataType, shape: Vec<usize>) -> Result<Self> {
450        let numel = shape.iter().try_fold(1usize, |product, &dim| {
451            product.checked_mul(dim).ok_or_else(|| {
452                SessionError::Internal(format!(
453                    "Tensor::allocate_cpu: element count overflows for shape {shape:?}"
454                ))
455            })
456        })?;
457        let bytes = dtype.checked_storage_bytes(numel).ok_or_else(|| {
458            SessionError::Internal(format!(
459                "Tensor::allocate_cpu: byte count overflows for shape {shape:?} dtype {dtype:?}"
460            ))
461        })?;
462        let allocator: Arc<dyn ExecutionProvider> = shared_cpu_ep();
463        let layout = TensorLayout::contiguous();
464        let buffer = allocator.allocate(bytes.max(1), layout.alignment)?;
465        Ok(Self {
466            dtype,
467            shape,
468            layout,
469            device: buffer.device(),
470            buffer: Some(buffer),
471            allocator,
472            import_guard: None,
473        })
474    }
475
476    pub(crate) fn copy_from_host_at(&mut self, offset: usize, bytes: &[u8]) -> Result<()> {
477        let buffer = self.buffer.as_mut().ok_or_else(|| {
478            SessionError::Internal("Tensor buffer is unavailable for writing".to_string())
479        })?;
480        self.allocator.copy_from_host_at(bytes, buffer, offset)?;
481        Ok(())
482    }
483
484    /// Allocate a tensor from raw little-endian element bytes using `allocator`.
485    ///
486    /// `bytes` must hold exactly `storage_bytes(numel)` bytes for `dtype` and
487    /// `shape`.
488    pub(crate) fn from_raw_in(
489        allocator: Arc<dyn ExecutionProvider>,
490        dtype: DataType,
491        shape: Vec<usize>,
492        bytes: &[u8],
493    ) -> Result<Self> {
494        let numel: usize = shape.iter().product();
495        let expected = dtype.storage_bytes(numel);
496        if bytes.len() != expected {
497            return Err(SessionError::Internal(format!(
498                "Tensor::from_raw_in: {} bytes for shape {shape:?} dtype {dtype:?}, expected {expected}",
499                bytes.len()
500            )));
501        }
502        let layout = TensorLayout::contiguous();
503        let align = layout.alignment;
504        let mut buffer = allocator.allocate(expected.max(1), align)?;
505        allocator.copy_from_host(bytes, &mut buffer)?;
506        Ok(Self {
507            dtype,
508            shape,
509            layout,
510            device: buffer.device(),
511            buffer: Some(buffer),
512            allocator,
513            import_guard: None,
514        })
515    }
516
517    /// Build a tensor from raw little-endian bytes on the shared CPU device.
518    pub fn from_raw(dtype: DataType, shape: Vec<usize>, bytes: &[u8]) -> Result<Self> {
519        Self::from_raw_in(shared_cpu_ep(), dtype, shape, bytes)
520    }
521
522    /// Take ownership of an already-allocated, contiguous `buffer` (allocated by
523    /// `allocator`) and wrap it as a row-major tensor **without copying**. The
524    /// executor uses this to hand a produced output's host buffer straight to
525    /// the caller instead of round-tripping the bytes through
526    /// [`copy_to_host`](ExecutionProvider::copy_to_host) plus a second
527    /// allocate+copy in [`from_raw`]. The bytes are the exact same memory the
528    /// kernel wrote, so this is numerically identical to the copy path.
529    ///
530    /// `buffer` must hold exactly `storage_bytes(numel)` bytes for `dtype` and
531    /// `shape`, must be host-resident, and must be owned (not borrowed) so the
532    /// tensor can free it exactly once on drop.
533    pub(crate) fn from_owned_buffer(
534        allocator: Arc<dyn ExecutionProvider>,
535        dtype: DataType,
536        shape: Vec<usize>,
537        buffer: DeviceBuffer,
538    ) -> Self {
539        debug_assert!(
540            !buffer.is_borrowed(),
541            "from_owned_buffer requires an owned buffer"
542        );
543        debug_assert_eq!(
544            buffer.len(),
545            dtype.storage_bytes(shape.iter().product::<usize>()).max(1),
546            "from_owned_buffer size mismatch for shape {shape:?} dtype {dtype:?}",
547        );
548        let device = buffer.device();
549        Self {
550            dtype,
551            shape,
552            layout: TensorLayout::contiguous(),
553            device,
554            buffer: Some(buffer),
555            allocator,
556            import_guard: None,
557        }
558    }
559
560    /// Build an `f32` tensor from a dense row-major slice.
561    pub fn from_f32(shape: &[usize], data: &[f32]) -> Result<Self> {
562        let mut bytes = Vec::with_capacity(data.len() * 4);
563        for v in data {
564            bytes.extend_from_slice(&v.to_le_bytes());
565        }
566        Self::from_raw(DataType::Float32, shape.to_vec(), &bytes)
567    }
568
569    /// Build an `i64` tensor from a dense row-major slice.
570    pub fn from_i64(shape: &[usize], data: &[i64]) -> Result<Self> {
571        let mut bytes = Vec::with_capacity(data.len() * 8);
572        for v in data {
573            bytes.extend_from_slice(&v.to_le_bytes());
574        }
575        Self::from_raw(DataType::Int64, shape.to_vec(), &bytes)
576    }
577
578    pub(crate) fn into_shared_parts(
579        mut self,
580    ) -> (Arc<SharedTensorBuffer>, DataType, Vec<usize>, TensorLayout) {
581        let buffer = self
582            .buffer
583            .take()
584            .expect("Tensor buffer taken only by into_shared_parts or Drop");
585        let storage = SharedTensorBuffer::with_guard(
586            Arc::clone(&self.allocator),
587            buffer,
588            self.import_guard.take(),
589        );
590        let dtype = self.dtype;
591        let shape = std::mem::take(&mut self.shape);
592        let layout = std::mem::take(&mut self.layout);
593        (storage, dtype, shape, layout)
594    }
595
596    /// The device this tensor lives on.
597    pub fn device(&self) -> DeviceId {
598        self.device
599    }
600
601    /// Wrap **foreign, borrowed** memory in a `Tensor`, with an opaque `guard`
602    /// that releases the foreign allocation when the tensor is dropped.
603    ///
604    /// This is the zero-copy *import* constructor: `buffer` must be a
605    /// **borrowed** [`DeviceBuffer`] (built via
606    /// [`DeviceBuffer::from_borrowed_parts`]) aliasing memory owned by whatever
607    /// `guard` boxes up — for a DLPack import, `guard` owns the foreign
608    /// `DLManagedTensor` and its `Drop` calls that tensor's `deleter` exactly
609    /// once. Because the buffer is borrowed, the owning EP's `deallocate` is a
610    /// no-op for it, so the *only* thing that frees the aliased memory is the
611    /// guard.
612    ///
613    /// # Ordering invariant
614    ///
615    /// [`Drop`] deallocates `buffer` (a no-op for a borrowed buffer) and only
616    /// **then** drops the guard, so the guard's deleter never runs while the
617    /// buffer still aliases the foreign memory. Do not rely on the guard freeing
618    /// anything the buffer still points at before `drop` completes.
619    ///
620    /// # Panics
621    ///
622    /// Panics (debug builds) if `buffer` is not borrowed — an owned buffer here
623    /// would be double-freed (once by the EP, once by the guard).
624    pub fn from_borrowed_parts_with_guard(
625        allocator: Arc<dyn ExecutionProvider>,
626        dtype: DataType,
627        shape: Vec<usize>,
628        layout: TensorLayout,
629        buffer: DeviceBuffer,
630        guard: Box<dyn core::any::Any + Send + Sync>,
631    ) -> Self {
632        debug_assert!(
633            buffer.is_borrowed(),
634            "from_borrowed_parts_with_guard requires a borrowed DeviceBuffer; \
635             an owned buffer would be freed twice (EP deallocate + guard)"
636        );
637        Self {
638            dtype,
639            shape,
640            layout,
641            device: buffer.device(),
642            buffer: Some(buffer),
643            allocator,
644            import_guard: Some(guard),
645        }
646    }
647
648    /// Number of elements.
649    pub fn numel(&self) -> usize {
650        self.shape.iter().product()
651    }
652
653    /// Base pointer of this tensor's backing allocation.
654    ///
655    /// For host-accessible devices (CPU, MLX) this is a dereferenceable host
656    /// pointer; for device memory (CUDA/ROCm) it is an **opaque device address**
657    /// only meaningful inside the owning EP's context — never dereference it on
658    /// the host. This is the device-agnostic base the zero-copy DLPack **export**
659    /// path hands to a consumer, so a CUDA-resident output can be borrowed as a
660    /// `kDLCUDA` tensor without a host round-trip. Returns null for an empty
661    /// (zero-element) tensor.
662    pub fn device_ptr(&self) -> *const std::ffi::c_void {
663        if self.numel() == 0 {
664            std::ptr::null()
665        } else {
666            self.buffer().as_ptr()
667        }
668    }
669
670    /// Block until all pending work on the owning EP's stream completes.
671    ///
672    /// Device-agnostic: the CPU EP's `sync` is a no-op, while the CUDA EP fully
673    /// synchronizes its compute stream. The DLPack **export** path calls this
674    /// before handing a `kDLCUDA` buffer to a foreign consumer, so the producer's
675    /// device work is guaranteed complete (and thus the data valid) regardless of
676    /// which stream the consumer reads on — the conservative, always-correct end
677    /// of the DLPack stream handshake.
678    pub fn sync(&self) -> Result<()> {
679        self.allocator.sync()?;
680        Ok(())
681    }
682
683    fn buffer(&self) -> &DeviceBuffer {
684        self.buffer
685            .as_ref()
686            .expect("Tensor buffer taken only in Drop")
687    }
688
689    /// Borrow the raw little-endian element bytes (host tensors only).
690    pub fn as_bytes(&self) -> &[u8] {
691        let n = self.dtype.storage_bytes(self.numel());
692        &host_bytes(self.buffer())[..n]
693    }
694
695    /// Replace this tensor's logical bytes without reallocating its backing
696    /// buffer. Used by control-flow iteration inputs whose dtype/shape stay
697    /// constant while their values change.
698    pub(crate) fn overwrite_bytes(&mut self, bytes: &[u8]) -> Result<()> {
699        let expected = self.dtype.storage_bytes(self.numel());
700        if bytes.len() != expected {
701            return Err(SessionError::Internal(format!(
702                "Tensor::overwrite_bytes: got {} bytes for shape {:?} dtype {:?}, expected {expected}",
703                bytes.len(),
704                self.shape,
705                self.dtype
706            )));
707        }
708        let buffer = self
709            .buffer
710            .as_mut()
711            .expect("Tensor buffer taken only in Drop");
712        self.allocator.copy_from_host(bytes, buffer)?;
713        Ok(())
714    }
715
716    /// Borrow the elements as `f32` without copying (host tensors only).
717    /// Returns `None` on big-endian or unexpectedly misaligned hosts; panics
718    /// only when called on a non-`Float32` tensor.
719    pub fn try_as_slice_f32(&self) -> Option<&[f32]> {
720        assert_eq!(
721            self.dtype,
722            DataType::Float32,
723            "try_as_slice_f32 on non-f32 tensor"
724        );
725        if cfg!(target_endian = "big") {
726            return None;
727        }
728        let bytes = self.as_bytes();
729        if bytes.as_ptr().align_offset(std::mem::align_of::<f32>()) != 0 {
730            return None;
731        }
732        // SAFETY: Float32 tensor storage contains `numel` contiguous, aligned
733        // f32 elements and the returned slice cannot outlive the tensor borrow.
734        Some(unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::<f32>(), self.numel()) })
735    }
736
737    /// Borrow Float16/BFloat16 storage as raw 16-bit elements without copying.
738    /// Returns `None` on big-endian or unexpectedly misaligned hosts.
739    pub fn try_as_slice_u16(&self) -> Option<&[u16]> {
740        assert!(
741            matches!(self.dtype, DataType::Float16 | DataType::BFloat16),
742            "try_as_slice_u16 on non-16-bit float tensor"
743        );
744        if cfg!(target_endian = "big") {
745            return None;
746        }
747        let bytes = self.as_bytes();
748        if bytes.as_ptr().align_offset(std::mem::align_of::<u16>()) != 0 {
749            return None;
750        }
751        // SAFETY: 16-bit float storage contains `numel` contiguous, aligned u16
752        // bit patterns and the returned slice cannot outlive the tensor borrow.
753        Some(unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::<u16>(), self.numel()) })
754    }
755
756    /// Copy out the elements as `f32`. Panics if the dtype is not `Float32`.
757    pub fn to_vec_f32(&self) -> Vec<f32> {
758        assert_eq!(
759            self.dtype,
760            DataType::Float32,
761            "to_vec_f32 on non-f32 tensor"
762        );
763        self.try_as_slice_f32().map_or_else(
764            || {
765                self.as_bytes()
766                    .chunks_exact(4)
767                    .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
768                    .collect()
769            },
770            <[f32]>::to_vec,
771        )
772    }
773
774    /// Copy out the elements as `i64`. Panics if the dtype is not `Int64`.
775    pub fn to_vec_i64(&self) -> Vec<i64> {
776        assert_eq!(self.dtype, DataType::Int64, "to_vec_i64 on non-i64 tensor");
777        self.as_bytes()
778            .chunks_exact(8)
779            .map(|c| i64::from_le_bytes(c.try_into().unwrap()))
780            .collect()
781    }
782}
783
784impl Clone for Tensor {
785    fn clone(&self) -> Self {
786        // Deep copy: a fresh allocation with identical bytes. Cannot fail for
787        // host allocations of the same size; propagate as a panic-free fallback
788        // by re-using `from_raw_in` and unwrapping the size-checked path.
789        Self::from_raw_in(
790            self.allocator.clone(),
791            self.dtype,
792            self.shape.clone(),
793            self.as_bytes(),
794        )
795        .expect("Tensor::clone: re-allocation of identical bytes")
796    }
797}
798
799impl std::fmt::Debug for Tensor {
800    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801        f.debug_struct("Tensor")
802            .field("dtype", &self.dtype)
803            .field("shape", &self.shape)
804            .field("device", &self.device)
805            .finish()
806    }
807}
808
809impl Drop for Tensor {
810    fn drop(&mut self) {
811        if let Some(buffer) = self.buffer.take() {
812            // `DeviceBuffer` has no `Drop`; the owning EP must free it exactly
813            // once (ep-api §4.4 invariant #2). Errors here cannot be surfaced
814            // from `drop`, so we swallow them — a failed free leaks, never
815            // double-frees.
816            let _ = self.allocator.deallocate(buffer);
817        }
818        // Release any foreign (DLPack-imported) allocation *after* the buffer
819        // aliasing it has been handed back to the EP. For a borrowed buffer the
820        // `deallocate` above is a no-op, so this guard's `Drop` (which runs the
821        // foreign deleter) is the sole owner that frees the memory — and it must
822        // run last, once the buffer no longer aliases it. `None` for tensors
823        // that own their allocation.
824        let _ = self.import_guard.take();
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use std::os::raw::c_void;
832    use std::sync::atomic::{AtomicUsize, Ordering};
833
834    /// A guard whose `Drop` bumps a shared counter — stands in for the DLPack
835    /// deleter the Python binding boxes into an imported tensor.
836    struct CountingGuard(Arc<AtomicUsize>);
837    impl Drop for CountingGuard {
838        fn drop(&mut self) {
839            self.0.fetch_add(1, Ordering::SeqCst);
840        }
841    }
842
843    #[test]
844    fn borrowed_guard_ctor_runs_guard_exactly_once_on_drop() {
845        let drops = Arc::new(AtomicUsize::new(0));
846        // Some real host memory the borrowed buffer can alias.
847        let mut backing = [1.0f32, 2.0, 3.0, 4.0];
848        let ptr = backing.as_mut_ptr() as *mut c_void;
849        // SAFETY: `backing` outlives the tensor built below; 16 bytes, 4-aligned.
850        let buffer = unsafe {
851            DeviceBuffer::from_borrowed_parts(ptr, DeviceId::cpu(), backing.len() * 4, 4)
852        };
853        assert!(buffer.is_borrowed());
854
855        let guard = Box::new(CountingGuard(drops.clone()));
856        let tensor = Tensor::from_borrowed_parts_with_guard(
857            shared_cpu_ep(),
858            DataType::Float32,
859            vec![4],
860            TensorLayout::contiguous(),
861            buffer,
862            guard,
863        );
864
865        // The tensor aliases the backing store without copying it.
866        assert_eq!(tensor.as_bytes().len(), 16);
867        assert_eq!(tensor.try_as_slice_f32().unwrap(), &[1.0, 2.0, 3.0, 4.0]);
868        assert_eq!(tensor.to_vec_f32(), vec![1.0, 2.0, 3.0, 4.0]);
869        assert_eq!(
870            drops.load(Ordering::SeqCst),
871            0,
872            "guard alive while tensor is"
873        );
874
875        drop(tensor);
876        assert_eq!(
877            drops.load(Ordering::SeqCst),
878            1,
879            "guard runs exactly once on drop"
880        );
881    }
882
883    #[test]
884    fn borrows_aligned_half_storage_as_raw_bits() {
885        let bits = [0x3c00u16, 0x4000];
886        let bytes = bits
887            .iter()
888            .flat_map(|value| value.to_le_bytes())
889            .collect::<Vec<_>>();
890        let tensor = Tensor::from_raw(DataType::Float16, vec![2], &bytes).unwrap();
891        assert_eq!(tensor.try_as_slice_u16().unwrap(), bits);
892    }
893
894    /// `exposes_logical_input_shape` is a *static* property of the binding (fixed
895    /// at allocation from the consumer-scoped capacity policy), whereas
896    /// `has_dynamic_logical_input_shape` additionally requires the current logical
897    /// shape to differ from physical. The single-token decode mask freeze relies
898    /// on this distinction: a mask that exposes its logical prefix must NOT be
899    /// frozen to physical capacity even while its logical shape still equals
900    /// physical at construction — otherwise the padded width leaks into a
901    /// non-capacity-aware consumer (GLM-5.2's indexer arithmetic).
902    #[test]
903    fn exposes_logical_is_static_while_dynamic_tracks_current_shape() {
904        // A mask-like binding that exposes its logical prefix, allocated with
905        // logical == physical (the state at decode construction time).
906        let mut logical_mask = DeviceIoBinding::allocate(
907            shared_cpu_ep(),
908            "attention_mask".into(),
909            true,
910            None,
911            DataType::Int64,
912            vec![1, 4096],
913            vec![1, 4096],
914            true,
915        )
916        .unwrap();
917        assert!(logical_mask.exposes_logical_input_shape());
918        // Not yet dynamic: logical still equals physical.
919        assert!(!logical_mask.has_dynamic_logical_input_shape());
920        // Kernels observe the logical prefix, so once decode drives the mask to
921        // the growing valid length it becomes dynamic (and forfeits capture).
922        logical_mask.set_logical_shape(vec![1, 5]).unwrap();
923        assert!(logical_mask.exposes_logical_input_shape());
924        assert!(logical_mask.has_dynamic_logical_input_shape());
925        assert_eq!(logical_mask.kernel_input_shape(), &[1, 5]);
926
927        // A capacity-exposing binding (all consumers padded-safe) never exposes
928        // its logical prefix: it stays frozen at physical capacity regardless of
929        // the current logical shape, so kernels always see the padded width.
930        let mut physical_mask = DeviceIoBinding::allocate(
931            shared_cpu_ep(),
932            "attention_mask".into(),
933            true,
934            None,
935            DataType::Int64,
936            vec![1, 4096],
937            vec![1, 5],
938            false,
939        )
940        .unwrap();
941        assert!(!physical_mask.exposes_logical_input_shape());
942        assert!(!physical_mask.has_dynamic_logical_input_shape());
943        assert_eq!(physical_mask.kernel_input_shape(), &[1, 4096]);
944        physical_mask.set_logical_shape(vec![1, 4096]).unwrap();
945        assert!(!physical_mask.exposes_logical_input_shape());
946    }
947}