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(not(edgefirst_tensor_dynamic))]`.
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(not(edgefirst_tensor_dynamic))]
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        // A non-zero address has at most BITS - 1 trailing zeros.
146        1usize << addr.trailing_zeros()
147    }
148
149    /// The pinned window as a byte slice.
150    ///
151    /// # Safety
152    ///
153    /// The caller must ensure no device write is in flight, and must have
154    /// bracketed with `sync_for_cpu` on non-coherent backends.
155    pub unsafe fn as_slice(&self) -> &[u8] {
156        if self.ptr.is_null() || self.len == 0 {
157            return &[];
158        }
159        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
160    }
161}
162
163/// Owner of an `mmap` whose lifetime is independent of any map guard.
164///
165/// Shared by every fd-backed backend (DMA-BUF, SHM): the address a pin hands
166/// out is a property of the mapping, so the mapping has to be owned by
167/// something with its own lifetime rather than by the guard that happened to
168/// create it. That single change is what lets a host pointer outlive a guard
169/// at all — see the module docs and issue #134.
170///
171/// `static`-gated as well as unix-gated: both backends that own an mmap
172/// (DMA-BUF and SHM) are storage, and only the `static` backend has
173/// storage. Under `dynamic` the mapping is owned on the far side of the
174/// ABI and reached through `ef_tensor_map`, so nothing here constructs one.
175#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
176pub(crate) struct MmapOwner {
177    ptr: std::ptr::NonNull<std::ffi::c_void>,
178    len: usize,
179}
180
181// SAFETY: the mapping is valid for `len` bytes until Drop, and MAP_SHARED
182// mappings are shareable across threads. Coherency is the caller's obligation
183// via sync_for_cpu / sync_for_device, exactly as for any map guard.
184#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
185unsafe impl Send for MmapOwner {}
186#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
187unsafe impl Sync for MmapOwner {}
188
189#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
190impl MmapOwner {
191    pub(crate) fn new(ptr: std::ptr::NonNull<std::ffi::c_void>, len: usize) -> Self {
192        MmapOwner { ptr, len }
193    }
194
195    /// Base address of the whole mapping, before any tensor offset.
196    pub(crate) fn base(&self) -> *mut u8 {
197        self.ptr.as_ptr() as *mut u8
198    }
199}
200
201/// Orders CPU mappings against GPU-driver calls that unmap an address range
202/// twice.
203///
204/// Adreno's proprietary EGL maps every imported DMA-BUF into the process at
205/// `eglCreateImage` and, inside a single `eglDestroyImage`, unmaps that range
206/// twice. A mapping another thread creates between the two `munmap`s can land
207/// in the freed range and is destroyed by the second one, so that thread's
208/// next access faults. Mappings are established under the shared side and
209/// such driver calls run under the exclusive side, which keeps this crate's
210/// own mappings out of that window.
211#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
212static CPU_MAPPING_GUARD: std::sync::RwLock<()> = std::sync::RwLock::new(());
213
214/// Shared side of [`CPU_MAPPING_GUARD`], held across each `mmap`.
215#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
216pub(crate) fn cpu_mapping_shared() -> std::sync::RwLockReadGuard<'static, ()> {
217    CPU_MAPPING_GUARD.read().unwrap_or_else(|e| e.into_inner())
218}
219
220/// Runs `f` while no tensor CPU mapping can be created in this process.
221///
222/// For a GPU-driver call that may unmap a range twice (Adreno
223/// `eglDestroyImage`). `f` must not map a tensor, or it deadlocks.
224#[doc(hidden)]
225#[cfg(not(edgefirst_tensor_dynamic))]
226pub fn with_cpu_mappings_excluded<R>(f: impl FnOnce() -> R) -> R {
227    #[cfg(unix)]
228    let _guard = CPU_MAPPING_GUARD.write().unwrap_or_else(|e| e.into_inner());
229    f()
230}
231
232/// The `dynamic` backend's [`with_cpu_mappings_excluded`]: excludes the
233/// library's mappings, which are the only ones this build creates.
234#[doc(hidden)]
235#[cfg(edgefirst_tensor_dynamic)]
236pub fn with_cpu_mappings_excluded<F: FnOnce() -> R, R>(f: F) -> R {
237    struct Call<F, R> {
238        f: Option<F>,
239        out: Option<std::thread::Result<R>>,
240    }
241    unsafe extern "C" fn trampoline<F: FnOnce() -> R, R>(ctx: *mut std::ffi::c_void) {
242        // SAFETY: `ctx` is the `Call` below, alive and exclusively borrowed
243        // for the duration of the export's synchronous callback.
244        let call = unsafe { &mut *ctx.cast::<Call<F, R>>() };
245        if let Some(f) = call.f.take() {
246            // A panic must not unwind through the C frame.
247            call.out = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)));
248        }
249    }
250    let mut call = Call {
251        f: Some(f),
252        out: None,
253    };
254    // SAFETY: the callback and its context stay valid for the whole call,
255    // and the export invokes the callback at most once, synchronously.
256    unsafe {
257        edgefirst_tensor_ffi::ef_tensor_with_cpu_mappings_excluded(
258            Some(trampoline::<F, R>),
259            (&mut call as *mut Call<F, R>).cast(),
260        )
261    };
262    match call
263        .out
264        .expect("ef_tensor_with_cpu_mappings_excluded skipped its callback")
265    {
266        Ok(r) => r,
267        Err(payload) => std::panic::resume_unwind(payload),
268    }
269}
270
271#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
272impl Drop for MmapOwner {
273    // munmap has no in-process observer that is not racy: probing the range
274    // afterwards can see another thread's new mapping at the same address.
275    #[cfg_attr(test, mutants::skip)]
276    fn drop(&mut self) {
277        // Nothing useful to do on failure at Drop time; the address space is
278        // reclaimed at process exit regardless.
279        unsafe {
280            let _ = nix::sys::mman::munmap(self.ptr, self.len);
281        }
282    }
283}
284
285impl std::fmt::Debug for HostPin<'_> {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        f.debug_struct("HostPin")
288            .field("ptr", &self.ptr)
289            .field("len", &self.len)
290            .field("alignment", &self.alignment())
291            .finish()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    /// A pin over a synthetic address; nothing here dereferences it.
300    fn pin_at(addr: usize, len: usize) -> HostPin<'static> {
301        HostPin::new(Arc::new(()), addr as *mut u8, len)
302    }
303
304    #[test]
305    fn is_empty_tracks_the_length() {
306        assert!(pin_at(0x1000, 0).is_empty());
307        assert!(!pin_at(0x1000, 1).is_empty());
308    }
309
310    #[test]
311    fn alignment_is_the_largest_power_of_two_dividing_the_address() {
312        assert_eq!(pin_at(0, 8).alignment(), 0);
313        assert_eq!(pin_at(0x6, 8).alignment(), 2);
314        assert_eq!(pin_at(0x1040, 8).alignment(), 64);
315        assert_eq!(pin_at(0x1000, 8).alignment(), 4096);
316        let top = 1usize << (usize::BITS - 1);
317        assert_eq!(pin_at(top, 8).alignment(), top);
318    }
319
320    #[test]
321    fn debug_names_the_type_and_its_extent() {
322        let text = format!("{:?}", pin_at(0x1040, 7));
323        assert!(text.contains("HostPin"), "{text}");
324        assert!(text.contains("len: 7"), "{text}");
325        assert!(text.contains("alignment: 64"), "{text}");
326    }
327}