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}