Skip to main content

edgefirst_tensor/
pin.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Persistent host mapping, decoupled from cache coherency.
5//!
6//! `map()` fuses two separate concerns: establishing a host address, and
7//! bracketing CPU access for coherency. Fusing them makes a whole class of
8//! consumer impossible to express — TFLite's `SetCustomAllocationForTensor`
9//! and ONNX Runtime's external tensors both retain a raw pointer for the
10//! interpreter's lifetime, far beyond any single access window
11//! ([#134](https://github.com/EdgeFirstAI/hal/issues/134)).
12//!
13//! The blocker is a borrow, not a lifetime: a map guard borrows the tensor,
14//! while `ImageProcessor::convert(&mut self, src, dst)` needs `&mut` on that
15//! same tensor every frame, and the two cannot coexist. So [`HostPin`] carries
16//! **no borrow of the tensor at all** — it holds an `Arc` keepalive over the
17//! memory instead, which is what lets a caller pin once and still mutate the
18//! tensor in the frame loop.
19//!
20//! IOSurface is the reference implementation this generalises: it already
21//! separates "address of the surface" (`IOSurfaceGetBaseAddress`, stable) from
22//! "CPU access window" (`IOSurfaceLock`), which is why the Apple path already
23//! works and the DMA-BUF one does not.
24
25use std::sync::Arc;
26
27/// Opaque keepalive holding whatever owns the pinned memory alive.
28///
29/// Type-erased so a pin does not leak the element type or the backend, which
30/// is what allows `HostPin` to be a single type across every backend.
31///
32/// Deliberately **not** `dyn Any`: `Any` carries a `'static` bound, and this is
33/// never downcast — it exists only so its `Drop` runs. Requiring `'static`
34/// would assert the allocation lives for the whole program, which is false for
35/// a tensor; the keepalive only has to outlive the pin, which `'a` says
36/// exactly.
37type Keepalive<'a> = Arc<dyn Send + Sync + 'a>;
38
39/// A stable host address for a tensor's data, valid until the pin is dropped.
40///
41/// Deliberately carries **no lifetime parameter and no reference to the
42/// tensor**: see the module docs. Cloning is cheap and shares the keepalive.
43#[derive(Clone)]
44pub struct HostPin<'a> {
45    /// Held, never read: dropping this is what releases the producer's
46    /// allocation, so its lifetime IS the guarantee the pinned address stays
47    /// valid. Clippy cannot see that a field's only purpose is its Drop.
48    #[allow(dead_code)]
49    keepalive: Keepalive<'a>,
50    ptr: *mut u8,
51    len: usize,
52}
53
54// SAFETY: the keepalive holds the owning allocation alive for the pin's life,
55// and every backend that yields a pin has a base pointer that is stable for
56// that allocation's lifetime. Sharing the address across threads is therefore
57// sound; synchronising *access* is the caller's job via sync_for_cpu /
58// sync_for_device, exactly as it is for the underlying buffer.
59unsafe impl Send for HostPin<'_> {}
60unsafe impl Sync for HostPin<'_> {}
61
62impl<'a> HostPin<'a> {
63    pub(crate) fn new(keepalive: Keepalive<'a>, ptr: *mut u8, len: usize) -> Self {
64        HostPin {
65            keepalive,
66            ptr,
67            len,
68        }
69    }
70
71    /// Narrow this pin to `len` bytes, keeping the same address and keepalive.
72    ///
73    /// Backends produce a pin covering everything addressable from the tensor's
74    /// offset (capacity, including any pitch padding); the public
75    /// [`pin_host`](crate::Tensor::pin_host) narrows that to the tensor's
76    /// logical extent so a consumer cannot mistake padding for data, while a
77    /// map guard keeps the wider window it needs to expose padded rows.
78    ///
79    /// Narrowing only ever shrinks: a `len` larger than the current one is
80    /// clamped, so this cannot widen a window beyond what was mapped.
81    ///
82    /// `static`-only: the sole caller, `Tensor::pin_host` (`impl<T>
83    /// Tensor<T>` in `lib.rs`), is itself `#[cfg(feature = "static")]`.
84    /// `dynamic`'s own `pin_host` (`dynamic_backend.rs`/`dynamic_tensor.rs`)
85    /// does not call this -- it hands back whatever extent `ef_tensor_map`
86    /// reports directly, unnarrowed.
87    #[cfg(feature = "static")]
88    pub(crate) fn narrowed(mut self, len: usize) -> Self {
89        self.len = self.len.min(len);
90        self
91    }
92
93    /// The pinned host address. Stable until this pin is dropped.
94    pub fn as_ptr(&self) -> *const u8 {
95        self.ptr
96    }
97
98    /// The pinned host address, mutable.
99    ///
100    /// Takes `&self` rather than `&mut self` on purpose: a raw pointer handed
101    /// out here can legitimately coexist with a `&mut` derived elsewhere (that
102    /// is the entire point of the pin), so pretending exclusivity via `&mut
103    /// self` would be a lie. Writing through it is the caller's obligation to
104    /// sequence — see [`crate::Tensor::sync_for_cpu`].
105    ///
106    /// # Safety contract
107    ///
108    /// The caller must not write while a device may be reading, and must
109    /// bracket CPU access with the sync calls on non-coherent backends.
110    pub fn as_mut_ptr(&self) -> *mut u8 {
111        self.ptr
112    }
113
114    /// The tensor's **logical** byte length, offset-adjusted for sub-region
115    /// views — not the backing allocation's capacity.
116    ///
117    /// Backends round allocations up (page size, pitch alignment), and every
118    /// backend reports the logical length so a consumer cannot mistake padding
119    /// for data. This is what TFLite's `SetCustomAllocationForTensor` wants.
120    ///
121    /// One consequence: for a **stride-padded** image the pinned window covers
122    /// `shape.product()` bytes, which is less than `row_stride * height`. Use a
123    /// map guard, which exposes the padded extent, when you need to write whole
124    /// padded rows.
125    pub fn len(&self) -> usize {
126        self.len
127    }
128
129    /// True when the pinned window addresses no bytes.
130    pub fn is_empty(&self) -> bool {
131        self.len == 0
132    }
133
134    /// Alignment of the pinned address, in bytes.
135    ///
136    /// TFLite requires 64-byte alignment (`kDefaultTensorAlignment`) unless the
137    /// caller opts out, and upstream warns that opting out can crash in
138    /// `Invoke()`. Page-backed mappings satisfy it, but a non-zero tensor
139    /// offset can break it — so callers can check rather than assume.
140    pub fn alignment(&self) -> usize {
141        let addr = self.ptr as usize;
142        if addr == 0 {
143            return 0;
144        }
145        1usize << addr.trailing_zeros().min(usize::BITS - 1)
146    }
147
148    /// The pinned window as a byte slice.
149    ///
150    /// # Safety
151    ///
152    /// The caller must ensure no device write is in flight, and must have
153    /// bracketed with `sync_for_cpu` on non-coherent backends.
154    pub unsafe fn as_slice(&self) -> &[u8] {
155        if self.ptr.is_null() || self.len == 0 {
156            return &[];
157        }
158        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
159    }
160}
161
162/// Owner of an `mmap` whose lifetime is independent of any map guard.
163///
164/// Shared by every fd-backed backend (DMA-BUF, SHM): the address a pin hands
165/// out is a property of the mapping, so the mapping has to be owned by
166/// something with its own lifetime rather than by the guard that happened to
167/// create it. That single change is what lets a host pointer outlive a guard
168/// at all — see the module docs and issue #134.
169///
170/// `static`-gated as well as unix-gated: both backends that own an mmap
171/// (DMA-BUF and SHM) are storage, and only the `static` backend has
172/// storage. Under `dynamic` the mapping is owned on the far side of the
173/// ABI and reached through `ef_tensor_map`, so nothing here constructs one.
174#[cfg(all(unix, feature = "static"))]
175pub(crate) struct MmapOwner {
176    ptr: std::ptr::NonNull<std::ffi::c_void>,
177    len: usize,
178}
179
180// SAFETY: the mapping is valid for `len` bytes until Drop, and MAP_SHARED
181// mappings are shareable across threads. Coherency is the caller's obligation
182// via sync_for_cpu / sync_for_device, exactly as for any map guard.
183#[cfg(all(unix, feature = "static"))]
184unsafe impl Send for MmapOwner {}
185#[cfg(all(unix, feature = "static"))]
186unsafe impl Sync for MmapOwner {}
187
188#[cfg(all(unix, feature = "static"))]
189impl MmapOwner {
190    pub(crate) fn new(ptr: std::ptr::NonNull<std::ffi::c_void>, len: usize) -> Self {
191        MmapOwner { ptr, len }
192    }
193
194    /// Base address of the whole mapping, before any tensor offset.
195    pub(crate) fn base(&self) -> *mut u8 {
196        self.ptr.as_ptr() as *mut u8
197    }
198}
199
200#[cfg(all(unix, feature = "static"))]
201impl Drop for MmapOwner {
202    fn drop(&mut self) {
203        // Nothing useful to do on failure at Drop time; the address space is
204        // reclaimed at process exit regardless.
205        unsafe {
206            let _ = nix::sys::mman::munmap(self.ptr, self.len);
207        }
208    }
209}
210
211impl std::fmt::Debug for HostPin<'_> {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("HostPin")
214            .field("ptr", &self.ptr)
215            .field("len", &self.len)
216            .field("alignment", &self.alignment())
217            .finish()
218    }
219}