edgefirst_tensor/lib.rs
1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5EdgeFirst HAL - Tensor Module
6
7The `edgefirst_tensor` crate provides a unified interface for managing multi-dimensional arrays (tensors)
8across four memory backends: the platform's native zero-copy GPU buffer, POSIX shared memory, the system
9heap, and an OpenGL Pixel Buffer Object. The crate defines traits and structures for creating, reshaping,
10and mapping tensors into memory.
11
12## Examples
13```rust
14use edgefirst_tensor::{Error, Tensor, TensorMemory, TensorTrait};
15# fn main() -> Result<(), Error> {
16let tensor = Tensor::<f32>::new(&[2, 3, 4], Some(TensorMemory::Mem), Some("test_tensor"))?;
17assert_eq!(tensor.memory(), TensorMemory::Mem);
18assert_eq!(tensor.name(), "test_tensor");
19# Ok(())
20# }
21```
22
23## Overview
24The main structures and traits provided by the `edgefirst_tensor` crate are [`TensorTrait`] and
25[`TensorMapTrait`], which define the behavior of Tensors and their memory mappings, respectively.
26The [`Tensor<T>`] struct wraps a backend-specific storage with optional image format metadata
27([`PixelFormat`]); mapping a tensor yields a [`HostView`], which implements
28[`TensorMapTrait`] and derefs to the underlying elements. The
29[`TensorDyn`] type-erased enum wraps `Tensor<T>` for runtime element-type dispatch.
30
31[`TensorMemory::DmaBuf`] is one variant with three implementations behind it — a Linux DMA-heap DMA-BUF,
32a macOS/iOS `IOSurfaceRef`, or an Android `AHardwareBuffer`. Callers ask for `Dma` and get whichever
33the platform provides, so portable code never branches on the mechanism.
34
35## Image tensors
36Images go through [`Tensor::image`] (or [`Tensor::image_desc`] for the full-featured request) rather
37than [`Tensor::new`]. Two things set them apart:
38
39- They take a required [`CpuAccess`] declaration. Hardware access — GPU, NPU, ISP, codec — is always
40 implied; what the CPU intends is the one thing the allocator cannot guess. Mapping beyond the
41 declaration is best-effort and always counted by [`unplanned_cpu_access_count`].
42- DMA-backed images carry a 64-byte-aligned row stride in every layout, because Mali and Vivante
43 reject an EGLImage import at an unaligned pitch. Read the real pitch from
44 [`Tensor::effective_row_stride`]; don't assume `width * bpp`.
45 */
46#[cfg(all(feature = "static", feature = "dynamic"))]
47compile_error!(
48 "edgefirst-tensor: `static` and `dynamic` are mutually exclusive. \
49 A consumer building the C-API leaves wants `--no-default-features \
50 --features dynamic`; everything else wants the default."
51);
52
53// The workspace declares `default-features = false` for this crate, so
54// nothing arrives here by accident anymore -- every consumer names a
55// backend explicitly. Before that change `static` was always a default and
56// this state was unreachable; now a manifest that forgot to ask for either
57// backend back builds an empty `tensor_dyn` module and fails on confusing
58// `E0432: unresolved import` errors instead of a message that names the
59// actual problem.
60#[cfg(not(any(feature = "static", feature = "dynamic")))]
61compile_error!(
62 "edgefirst-tensor: exactly one backend must be enabled. This crate has \
63 `default-features = false` at the workspace level, so every consumer \
64 must name one explicitly: `features = [\"static\"]` for Rust consumers, \
65 or `[\"dynamic\"]` for the C-API leaves."
66);
67
68// `static`-only: every constructor/accessor here is reached exclusively
69// through `TensorStorage`'s `AHardwareBuffer` variant (`impl<T> Tensor<T>`,
70// gated `#[cfg(feature = "static")]`); under `dynamic` nothing calls into
71// it, which clippy's dead-code lint on Android correctly flags without this
72// gate. Not `#[allow]`-ed: an `allow` here would hide the next real
73// dead-code signal in this module, the opposite of what a gate does.
74#[cfg(all(target_os = "android", feature = "static"))]
75mod ahardwarebuffer;
76// Pure AHardwareBuffer layout logic (format table, descriptor geometry,
77// overflow-checked shape math) — cfg-free so it compiles and unit-tests
78// on every host; the android module above consumes it.
79#[allow(dead_code)]
80mod ahardwarebuffer_layout;
81pub mod colorimetry;
82#[cfg(feature = "tracing")]
83pub mod trace;
84
85pub mod covguard;
86mod cuda;
87mod detect;
88// `static`-gated as well as Linux-gated. `DmaTensor` is storage, and only
89// the `static` backend has storage -- `TensorStorage` itself is already
90// `#[cfg(feature = "static")]`, and the `dynamic` backend reaches a
91// DMA-BUF's fd through `ef_tensor_plane_at` instead of ever constructing
92// one. Until task P2b's F3, this module was kept alive under `dynamic` by a
93// single always-`None` `Tensor::as_dma` whose signature named `DmaTensor`;
94// removing that stub revealed the whole module (the tensor, the sync
95// ioctls, the DRM PRIME helpers, the mmap owner) as dead code there --
96// twenty-six dead-code warnings' worth. It was always dead; one method
97// hid it.
98#[cfg(all(target_os = "linux", feature = "static"))]
99mod dma;
100#[cfg(all(target_os = "linux", feature = "static"))]
101mod dmabuf;
102mod error;
103mod format;
104// Layout table is backend-agnostic (pure FourCC math). Storage
105// (`IoSurfaceTensor`) is `static`-only; `dynamic` reads IOSurface handles
106// through `ef_tensor_*`.
107#[cfg(any(target_os = "macos", target_os = "ios"))]
108mod iosurface_layout;
109// `static`-only, same reasoning as `ahardwarebuffer` above: reached only
110// through `TensorStorage`'s `IoSurface` variant.
111#[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "static"))]
112mod iosurface;
113pub mod lens;
114// `static`-only: `MemTensor` backs `TensorStorage::Mem`, `impl<T> Tensor<T>`
115// only. `dynamic`'s host-memory allocation goes through `ef_tensor_new`/
116// `ef_tensor_builder_alloc` in `libedgefirst_tensor.so` instead -- there is
117// no local `MemTensor` under `dynamic` to be dead code, so the whole module
118// gates rather than an internal `#[allow]`.
119#[cfg(feature = "static")]
120mod mem;
121mod pbo;
122pub mod pin;
123pub mod protocol;
124// `static`-only, same reasoning as `mem` above: `ShmTensor` backs
125// `TensorStorage::Shm`, `impl<T> Tensor<T>` only.
126#[cfg(all(unix, feature = "static"))]
127mod shm;
128mod tensor_dyn;
129pub mod view;
130mod vocabulary;
131pub use colorimetry::{
132 ColorEncoding, ColorRange, ColorSpace, ColorTransfer, Colorimetry, MatrixWeights, RangeScaling,
133};
134
135/// Retained constructor: installs the coverage flush-on-abort handler for this
136/// crate's instrumented test binary. See `covguard`. Only present under
137/// coverage on Linux (`.init_array` is ELF-only; the i.MX flush is Linux-only).
138#[cfg(all(coverage, target_os = "linux"))]
139#[used]
140#[link_section = ".init_array"]
141static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
142 extern "C" fn ctor() {
143 crate::covguard::install();
144 }
145 ctor
146};
147
148// Backing tensor/map types are internal implementation details: callers
149// allocate `Tensor<T>` / `TensorDyn` and map them, never naming the per-memory
150// backing types directly. They are `pub(crate)` so they stay nameable for the
151// `TensorStorage` / `TensorMap` enums without leaking into the public API.
152// Exceptions kept public: `Pbo*` is a GL extension point implemented by the
153// image crate, and `image_iosurface_layout` is a public helper.
154#[cfg(all(target_os = "android", feature = "static"))]
155pub use crate::ahardwarebuffer::image_ahardwarebuffer_layout;
156#[cfg(all(target_os = "android", feature = "static"))]
157pub(crate) use crate::ahardwarebuffer::AHardwareBufferTensor;
158#[cfg(all(target_os = "linux", feature = "static"))]
159pub(crate) use crate::dma::DmaTensor;
160#[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "static"))]
161pub(crate) use crate::iosurface::IoSurfaceTensor;
162#[cfg(any(target_os = "macos", target_os = "ios"))]
163pub use crate::iosurface_layout::image_iosurface_layout;
164#[cfg(feature = "static")]
165pub(crate) use crate::mem::MemTensor;
166pub use crate::pbo::{PboMapping, PboOps, PboTensor};
167#[cfg(all(unix, feature = "static"))]
168pub(crate) use crate::shm::ShmTensor;
169pub use cuda::{
170 gl_map_resource, gl_register_buffer, gl_unmap_resource, gl_unregister_resource,
171 is_cuda_available, memcpy_device_to_host, stream_create, stream_destroy, stream_synchronize,
172 CudaGlOps, CudaHandle, CudaMap, CudaStream,
173};
174pub use detect::{unletter_norm, BoundingBox, DetectBox, ProtoData, ProtoLayout, Segmentation};
175pub use error::{Error, Result};
176// Static-backend-implementation code: backs `tensor-capi`'s own
177// `ef_tensor_export`/`ef_tensor_import` (the static cdylib's `TensorDyn`
178// serialization), the one caller in the whole workspace. It calls
179// `TensorDyn::new`/`from_fd` and reads fields (`.quantization()`,
180// `.colorimetry()`, `.pin_host()`, ...) that only the `static` backend's
181// richer `TensorDyn` surface has, so it never compiled under `dynamic` in
182// fact even though nothing gated it -- see `PRIMITIVE-INVENTORY.md`'s
183// "structural findings".
184#[cfg(feature = "static")]
185pub mod blob;
186#[cfg(feature = "static")]
187pub use blob::{
188 BlobError, BlobHeader, BlobPlane, BlobStrings, BlobView, Regions, TransportMode, HEADER_LEN,
189 PLANE_RECORD_LEN,
190};
191pub use format::{ChromaLayout, PixelFormat, PixelLayout, PlaneGeometry};
192pub use lens::Element;
193use num_traits::Num;
194pub use pin::HostPin;
195pub use protocol::{
196 dtype as tensor_dtype, dtype_to_dtype, format as tensor_format, format_from_code,
197 kind as tensor_kind, SendPtr, TensorDesc, ABI_VERSION,
198};
199use serde::{Deserialize, Serialize};
200#[cfg(unix)]
201use std::os::fd::OwnedFd;
202use std::{
203 fmt,
204 sync::{
205 atomic::{AtomicU64, Ordering},
206 Arc, Weak,
207 },
208};
209// `static`'s `Tensor<T>` is declared directly above, in this file; there is
210// nothing to re-export for it. `dynamic`'s lives in `tensor_dyn` (see that
211// module's docs) and is re-exported here under the same crate-root name so
212// both backends present the same `edgefirst_tensor::Tensor` to callers.
213#[cfg(feature = "dynamic")]
214pub use tensor_dyn::Tensor;
215pub use tensor_dyn::{Raw, TensorDyn};
216pub use view::HostView;
217pub use vocabulary::vocabulary_demo;
218
219/// Opaque keep-alive handle for a foreign-memory tensor (see
220/// [`Tensor::from_foreign`] / [`TensorDyn::from_foreign_ptr`]).
221///
222/// The HAL borrows the foreign buffer without owning it; this handle co-owns
223/// the *source* so the borrowed memory stays valid for the tensor's life. Its
224/// `Drop` releases the source — e.g. a small struct that calls `cudaFreeHost`,
225/// or a `Py<PyAny>` that decrements a NumPy array's refcount. Wrapping it in an
226/// `Arc` (then boxing each clone) makes the release fire exactly once, after
227/// the last sharing tensor/view/map drops, regardless of drop order.
228pub type ForeignOwner = Box<dyn std::any::Any + Send + Sync>;
229
230/// Re-export of `half::f16` so downstream crates can write
231/// `Tensor::<edgefirst_tensor::f16>::from_iosurface(…)` without
232/// adding `half` to their own dependency list. The version stays in
233/// lockstep with the `half` workspace dep.
234pub use half::f16;
235
236// =============================================================================
237// RGBA16F packed-layout geometry — single source of truth
238//
239// A `PlanarRgb` [3,H,W] or `PlanarRgba` [4,H,W] f16 tensor is represented
240// on the GPU as an RGBA16F surface (the only float format accepted by the
241// ANGLE IOSurface extension). Four contiguous f16 elements are packed into
242// each 8-byte RGBA16F texel, yielding a `(W/4, C*H)` surface.
243//
244// All call sites that need these dimensions must use `packed_rgba16f_layout`
245// so the rule lives in exactly one place. Currently consumed by:
246// - `crates/tensor/src/iosurface.rs` `new_image` (macOS IOSurface alloc)
247// - `crates/image/src/gl/iosurface_import.rs` (macOS GL IOSurface import)
248// - `crates/image/src/gl/processor/float.rs` (Linux GL float render — PBO
249// readback and DMA-BUF, also via the `dma_f16_packed_layout` wrapper)
250// =============================================================================
251
252/// Geometry of the RGBA16F-packed surface backing a planar F16 image tensor.
253///
254/// ANGLE only supports one float `(type, internal_format)` pair for IOSurface
255/// import: `(GL_HALF_FLOAT, GL_RGBA)` = RGBA16F (8 bytes/texel). To map a
256/// `[C, H, W]` f16 planar tensor onto such a surface, 4 contiguous f16
257/// elements are packed into each RGBA16F texel, yielding a surface of
258/// `(W/4, C*H)` texels at 8 bytes/texel. The byte stream is identical to a
259/// (nonexistent) R16F `(W, C*H)` surface and can be consumed as `&[f16]`
260/// with shape `[1, C, H, W]` without rearrangement.
261///
262/// Obtain via [`packed_rgba16f_layout`] — never construct directly.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub struct PackedRgba16fLayout {
265 /// Surface width in texels (`width / 4`).
266 pub surface_w: usize,
267 /// Surface height in texels (`planes * height`).
268 pub surface_h: usize,
269 /// Bytes per RGBA16F texel (always 8).
270 pub bytes_per_texel: usize,
271 /// Row pitch in bytes (`surface_w * 8`).
272 pub pitch: usize,
273}
274
275/// Canonical geometry for the RGBA16F-packed surface backing a planar F16
276/// image tensor.
277///
278/// Returns `Some(layout)` only when **all** of the following hold:
279///
280/// - `dtype == DType::F16`
281/// - `format` is `PixelFormat::PlanarRgb` (3 planes) or
282/// `PixelFormat::PlanarRgba` (4 planes)
283/// - `width % 4 == 0`
284///
285/// Returns `None` for any other `(format, dtype)` combination, misaligned
286/// width, or when the surface geometry would overflow `usize` — callers
287/// must fall back to a non-packed path or return a context-appropriate
288/// error.
289///
290/// # Examples
291///
292/// ```rust
293/// use edgefirst_tensor::{packed_rgba16f_layout, PixelFormat, DType};
294///
295/// let layout = packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 480).unwrap();
296/// assert_eq!(layout.surface_w, 160);
297/// assert_eq!(layout.surface_h, 1440);
298/// assert_eq!(layout.bytes_per_texel, 8);
299/// assert_eq!(layout.pitch, 1280);
300/// ```
301pub fn packed_rgba16f_layout(
302 format: PixelFormat,
303 dtype: DType,
304 width: usize,
305 height: usize,
306) -> Option<PackedRgba16fLayout> {
307 if dtype != DType::F16 {
308 return None;
309 }
310 let planes: usize = match format {
311 PixelFormat::PlanarRgb => 3,
312 PixelFormat::PlanarRgba => 4,
313 _ => return None,
314 };
315 if !width.is_multiple_of(4) {
316 return None;
317 }
318 let surface_w = width / 4;
319 // Checked arithmetic: a degenerate (height, width) could otherwise wrap
320 // and yield an under-sized layout, which downstream allocators trust for
321 // GPU/CPU buffer sizing. Overflow → None (handled like any other
322 // unsupported geometry).
323 let surface_h = planes.checked_mul(height)?;
324 let bytes_per_texel = 8;
325 let pitch = surface_w.checked_mul(bytes_per_texel)?;
326 Some(PackedRgba16fLayout {
327 surface_w,
328 surface_h,
329 bytes_per_texel,
330 pitch,
331 })
332}
333
334/// Geometry of the RGBA8888-packed surface backing a packed RGB u8/i8 image
335/// tensor.
336///
337/// GPUs have no 3-channel renderable format, so the GL engine's two-pass
338/// packed-RGB shader writes the tight `[H, W, 3]` byte stream into an
339/// RGBA8888 surface: each texel carries 4 consecutive RGB bytes, giving a
340/// `(W*3/4, H)` surface at 4 bytes/texel whose rows are byte-identical to
341/// tight RGB — consumable flat as `[H, W, 3]` with no rearrangement (the
342/// u8/i8 analog of [`packed_rgba16f_layout`]; i8 shares the layout since
343/// INT8 quantization is a per-byte `^0x80` bias, not a format change).
344///
345/// Returns `Some(layout)` only when `width % 4 == 0` (so `W*3` bytes divide
346/// into whole texels) and the geometry does not overflow `usize`.
347///
348/// # Examples
349///
350/// ```rust
351/// use edgefirst_tensor::packed_rgb888_layout;
352///
353/// let layout = packed_rgb888_layout(640, 480).unwrap();
354/// assert_eq!(layout.surface_w, 480); // 640*3/4
355/// assert_eq!(layout.surface_h, 480);
356/// assert_eq!(layout.bytes_per_texel, 4);
357/// assert_eq!(layout.pitch, 1920); // 640*3
358/// assert!(packed_rgb888_layout(641, 480).is_none());
359/// ```
360pub fn packed_rgb888_layout(width: usize, height: usize) -> Option<PackedRgb888Layout> {
361 if !width.is_multiple_of(4) {
362 return None;
363 }
364 let row_bytes = width.checked_mul(3)?;
365 let surface_w = row_bytes / 4;
366 Some(PackedRgb888Layout {
367 surface_w,
368 surface_h: height,
369 bytes_per_texel: 4,
370 pitch: row_bytes,
371 })
372}
373
374/// Geometry of the RGBA8888 surface backing a packed RGB u8/i8 image tensor.
375///
376/// Obtain via [`packed_rgb888_layout`] — never construct directly.
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub struct PackedRgb888Layout {
379 /// Surface width in texels (`width * 3 / 4`).
380 pub surface_w: usize,
381 /// Surface height in texels (`height`).
382 pub surface_h: usize,
383 /// Bytes per RGBA8888 texel (always 4).
384 pub bytes_per_texel: usize,
385 /// Row pitch in bytes (`surface_w * 4` = `width * 3`).
386 pub pitch: usize,
387}
388
389/// Per-plane DMA-BUF descriptor for external buffer import.
390///
391/// Owns a duplicated file descriptor plus optional stride and offset metadata.
392/// The fd is duplicated eagerly in [`new()`](Self::new) so that a bad fd is
393/// caught immediately. `import_image` consumes the descriptor and takes
394/// ownership of the duped fd — no further cleanup is needed by the caller.
395///
396/// # Examples
397///
398/// ```rust,no_run
399/// use edgefirst_tensor::PlaneDescriptor;
400/// use std::os::fd::BorrowedFd;
401///
402/// // SAFETY: fd 42 is hypothetical; real code must pass a valid fd.
403/// let pd = unsafe { PlaneDescriptor::new(BorrowedFd::borrow_raw(42)) }
404/// .unwrap()
405/// .with_stride(2048)
406/// .with_offset(0);
407/// ```
408#[cfg(unix)]
409pub struct PlaneDescriptor {
410 fd: OwnedFd,
411 stride: Option<usize>,
412 offset: Option<usize>,
413}
414
415#[cfg(unix)]
416impl PlaneDescriptor {
417 /// Create a new plane descriptor by duplicating the given file descriptor.
418 ///
419 /// The fd is duped immediately — a bad fd fails here rather than inside
420 /// `import_image`. The caller retains ownership of the original fd.
421 ///
422 /// # Errors
423 ///
424 /// Returns an error if the `dup()` syscall fails (e.g. invalid fd or
425 /// fd limit reached).
426 pub fn new(fd: std::os::fd::BorrowedFd<'_>) -> Result<Self> {
427 let owned = fd.try_clone_to_owned()?;
428 Ok(Self {
429 fd: owned,
430 stride: None,
431 offset: None,
432 })
433 }
434
435 /// Set the row stride in bytes (consuming builder).
436 pub fn with_stride(mut self, stride: usize) -> Self {
437 self.stride = Some(stride);
438 self
439 }
440
441 /// Set the plane offset in bytes (consuming builder).
442 pub fn with_offset(mut self, offset: usize) -> Self {
443 self.offset = Some(offset);
444 self
445 }
446
447 /// Consume the descriptor and return the owned file descriptor.
448 pub fn into_fd(self) -> OwnedFd {
449 self.fd
450 }
451
452 /// Row stride in bytes, if set.
453 pub fn stride(&self) -> Option<usize> {
454 self.stride
455 }
456
457 /// Plane offset in bytes, if set.
458 pub fn offset(&self) -> Option<usize> {
459 self.offset
460 }
461}
462
463/// A rectangular sub-region of a tensor's leading spatial frame, in pixels.
464///
465/// `Region` is the single rectangle type in the workspace: the argument to
466/// [`Tensor::view`], the source sampling window in the image crate's `Crop`,
467/// and the geometry the image backend lowers to a `glViewport` (a destination
468/// tile) or a sampling rectangle (a source). Coordinates are pixel/element
469/// units of the leading spatial axes; byte addressing is derived from the
470/// parent's row stride, not stored here.
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub struct Region {
473 pub x: usize,
474 pub y: usize,
475 pub width: usize,
476 pub height: usize,
477}
478
479impl Region {
480 /// Create a region at `(x, y)` spanning `width` × `height` pixels.
481 pub fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
482 Self {
483 x,
484 y,
485 width,
486 height,
487 }
488 }
489
490 /// True when the region lies fully within a `width` × `height` frame.
491 pub fn fits_within(&self, width: usize, height: usize) -> bool {
492 self.x.saturating_add(self.width) <= width && self.y.saturating_add(self.height) <= height
493 }
494}
495
496/// The parent image a [`view`](Tensor::view)/[`batch`](Tensor::batch) sub-region
497/// was carved from, snapshotted at the time the view was created.
498///
499/// A view shares the parent's `BufferIdentity` and addresses a sub-rectangle of
500/// it. The GL backend keys its EGLImage import on the **parent** geometry (so all
501/// sibling views of one buffer collapse to a single import) and renders each
502/// view as a `glViewport`+`glScissor` ROI at `(x, y, width, height)` within that
503/// parent — the view is render state, never a distinct import. `parent_width`/
504/// `parent_height` are the parent's logical pixel dimensions; `x`/`y` are this
505/// view's top-left origin within the parent (pixels). Nested views compose:
506/// the snapshot always names the **root** parent, with offsets accumulated.
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub struct ViewOrigin {
509 /// Logical width of the root parent image, in pixels.
510 pub parent_width: usize,
511 /// Logical height of the root parent image, in pixels. For a `batch(n)` view
512 /// of an `[N, H, W, C]` tensor this is `N * H` (the tiles stack vertically in
513 /// the shared buffer).
514 pub parent_height: usize,
515 /// The parent's row stride in **bytes**. The GL backend keys its EGLImage
516 /// import/cache and pitch on this — NOT on the view's own `row_stride`, which
517 /// a single-row view sets tight (for map-span safety). Using the parent
518 /// stride keeps the import pitch parent-consistent so single-row and
519 /// multi-row sibling views collapse onto the same parent import.
520 pub parent_row_stride: usize,
521 /// This view's top-left x origin within the root parent, in pixels.
522 pub x: usize,
523 /// This view's top-left y origin within the root parent, in pixels.
524 pub y: usize,
525}
526
527// Declared through `ef_vocabulary!` (see `vocabulary.rs`): `.code()` is this
528// crate's wire numbering, mirrored by `protocol::dtype` and the C ABI's
529// `hal_dtype`. Both surfaces are generated from the same per-variant literal
530// below rather than re-typed by hand, and `dtype_codes_are_unchanged_by_the_macro`
531// in `tests/vocabulary.rs` pins them against the values already shipping.
532crate::ef_vocabulary! {
533 /// Element type discriminant for runtime type identification.
534 #[derive(Serialize, Deserialize)]
535 #[non_exhaustive]
536 pub enum DType {
537 U8 = 0, "u8", U8,
538 I8 = 1, "i8", I8,
539 U16 = 2, "u16", U16,
540 I16 = 3, "i16", I16,
541 U32 = 4, "u32", U32,
542 I32 = 5, "i32", I32,
543 U64 = 6, "u64", U64,
544 I64 = 7, "i64", I64,
545 F16 = 8, "f16", F16,
546 F32 = 9, "f32", F32,
547 F64 = 10, "f64", F64,
548 }
549 // `#[doc(hidden)]`: this module has to be `pub` for `protocol::dtype`'s
550 // `pub use` re-export below to compile, but it is emission plumbing, not
551 // a second documented API for the same eleven codes -- `protocol::dtype`
552 // is the canonical, documented path.
553 #[doc(hidden)]
554 pub mod dtype_wire;
555}
556
557impl DType {
558 /// Size of one element in bytes.
559 pub const fn size(&self) -> usize {
560 match self {
561 Self::U8 | Self::I8 => 1,
562 Self::U16 | Self::I16 | Self::F16 => 2,
563 Self::U32 | Self::I32 | Self::F32 => 4,
564 Self::U64 | Self::I64 | Self::F64 => 8,
565 }
566 }
567
568 /// Short type name (e.g., "u8", "f32", "f16"). Identical to
569 /// [`DType::as_str`]; kept as its own method for existing callers.
570 pub fn name(&self) -> &'static str {
571 self.as_str()
572 }
573}
574
575impl fmt::Display for DType {
576 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
577 f.write_str(self.name())
578 }
579}
580
581/// Map a static numeric type `T` to its `DType` discriminant, returning
582/// `None` for types that do not have a `DType` representation (e.g.
583/// user-defined wrappers in tests).
584///
585/// Runtime dtype of a static `Tensor<T>` element type — a safe `TypeId`
586/// allowlist of HAL's primitive numeric types (`Some` for `u8`..`i64` /
587/// `f16` / `f32` / `f64`, `None` otherwise). Used by the macOS IOSurface
588/// image constructors (FourCC / pixel-format lookup) and by the `Mem`
589/// backing's `alloc_zeroed` fast path (these types' `T::zero()` is the
590/// all-zeros bit pattern), so it is compiled on every target -- but only
591/// under `static`: every call site lives in `impl<T> Tensor<T>`, itself
592/// `#[cfg(feature = "static")]`.
593#[cfg(feature = "static")]
594pub(crate) fn dtype_of<T: 'static>() -> Option<DType> {
595 use std::any::TypeId;
596 let id = TypeId::of::<T>();
597 if id == TypeId::of::<u8>() {
598 Some(DType::U8)
599 } else if id == TypeId::of::<i8>() {
600 Some(DType::I8)
601 } else if id == TypeId::of::<u16>() {
602 Some(DType::U16)
603 } else if id == TypeId::of::<i16>() {
604 Some(DType::I16)
605 } else if id == TypeId::of::<u32>() {
606 Some(DType::U32)
607 } else if id == TypeId::of::<i32>() {
608 Some(DType::I32)
609 } else if id == TypeId::of::<u64>() {
610 Some(DType::U64)
611 } else if id == TypeId::of::<i64>() {
612 Some(DType::I64)
613 } else if id == TypeId::of::<half::f16>() {
614 Some(DType::F16)
615 } else if id == TypeId::of::<f32>() {
616 Some(DType::F32)
617 } else if id == TypeId::of::<f64>() {
618 Some(DType::F64)
619 } else {
620 None
621 }
622}
623
624// =============================================================================
625// Quantization metadata — type-gated to integer element types via sealed
626// `IntegerType` trait. Accessors on `Tensor<T>` only compile when `T` is
627// an integer type; calling them on `Tensor<f32>` / `Tensor<f16>` etc. is a
628// compile error, not a runtime one.
629// =============================================================================
630
631mod sealed {
632 pub trait Sealed {}
633 impl Sealed for u8 {}
634 impl Sealed for i8 {}
635 impl Sealed for u16 {}
636 impl Sealed for i16 {}
637 impl Sealed for u32 {}
638 impl Sealed for i32 {}
639 impl Sealed for u64 {}
640 impl Sealed for i64 {}
641 // f16 / f32 / f64 are sealed too (crate::lens::Element covers all
642 // eleven element types), but deliberately NOT given `IntegerType` below.
643 impl Sealed for half::f16 {}
644 impl Sealed for f32 {}
645 impl Sealed for f64 {}
646}
647
648/// Integer element types that may carry quantization metadata.
649///
650/// Sealed trait: implemented for `u8`, `i8`, `u16`, `i16`, `u32`, `i32`,
651/// `u64`, `i64`. Cannot be implemented downstream. Float element types
652/// (`half::f16`, `f32`, `f64`) are explicitly excluded — quantization
653/// metadata does not apply to float tensors per the edgefirst.json spec.
654pub trait IntegerType: sealed::Sealed {}
655impl IntegerType for u8 {}
656impl IntegerType for i8 {}
657impl IntegerType for u16 {}
658impl IntegerType for i16 {}
659impl IntegerType for u32 {}
660impl IntegerType for i32 {}
661impl IntegerType for u64 {}
662impl IntegerType for i64 {}
663
664/// Quantization parameters for an integer tensor.
665///
666/// Covers all four modes the edgefirst.json spec defines:
667///
668/// | Mode | `scale.len()` | `zero_point` | `axis` |
669/// |---|---|---|---|
670/// | Per-tensor symmetric | 1 | `None` | `None` |
671/// | Per-tensor asymmetric | 1 | `Some(len == 1)` | `None` |
672/// | Per-channel symmetric | >1 | `None` | `Some(c)` |
673/// | Per-channel asymmetric | >1 | `Some(len == scale.len())` | `Some(c)` |
674///
675/// The quantized storage type is carried on the parent [`Tensor<T>`]; this
676/// struct does not duplicate it. Construct via the four named constructors
677/// (the only public entry points); direct field mutation is not allowed so
678/// invalid combinations cannot be represented.
679///
680/// Dequantization formula:
681///
682/// ```text
683/// real_value = scale[c] × (quantized_value[c] - zero_point[c])
684/// ```
685///
686/// where `c` is the channel index (always `0` for per-tensor).
687#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
688pub struct Quantization {
689 /// Per-tensor: `vec![scale]`. Per-channel: `vec![scale_0, scale_1, ...]`.
690 #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
691 scale: Vec<f32>,
692
693 /// `None` means symmetric (zero-point is 0). `Some(vec)` must have the
694 /// same length as `scale`.
695 #[serde(
696 default,
697 deserialize_with = "deserialize_opt_scalar_or_vec_i32",
698 skip_serializing_if = "Option::is_none"
699 )]
700 zero_point: Option<Vec<i32>>,
701
702 /// Channel axis for per-channel quantization. `Some(_)` iff
703 /// `scale.len() > 1`. Validated against the parent tensor's shape at
704 /// `set_quantization()` time.
705 #[serde(default, skip_serializing_if = "Option::is_none")]
706 axis: Option<usize>,
707}
708
709/// Semantic mode discriminant for hot-path kernel dispatch.
710///
711/// Obtain via [`Quantization::mode`] once at kernel entry; never inside a
712/// pixel-level loop. The enum is borrow-based so the hot kernel receives
713/// the scales / zero-points as slices without reallocation.
714#[derive(Debug, Clone, Copy)]
715pub enum QuantMode<'a> {
716 PerTensorSymmetric {
717 scale: f32,
718 },
719 PerTensor {
720 scale: f32,
721 zero_point: i32,
722 },
723 PerChannelSymmetric {
724 scales: &'a [f32],
725 axis: usize,
726 },
727 PerChannel {
728 scales: &'a [f32],
729 zero_points: &'a [i32],
730 axis: usize,
731 },
732}
733
734impl Quantization {
735 /// Per-tensor symmetric (zero_point = 0).
736 pub fn per_tensor_symmetric(scale: f32) -> Self {
737 Self {
738 scale: vec![scale],
739 zero_point: None,
740 axis: None,
741 }
742 }
743
744 /// Per-tensor asymmetric — the most common runtime shape.
745 pub fn per_tensor(scale: f32, zero_point: i32) -> Self {
746 Self {
747 scale: vec![scale],
748 zero_point: Some(vec![zero_point]),
749 axis: None,
750 }
751 }
752
753 /// Per-channel symmetric. Errors on empty `scales`.
754 pub fn per_channel_symmetric(scales: Vec<f32>, axis: usize) -> Result<Self> {
755 if scales.is_empty() {
756 return Err(Error::QuantizationInvalid {
757 field: "scale.len",
758 expected: "non-empty per-channel scales".to_string(),
759 got: "length 0".to_string(),
760 });
761 }
762 Ok(Self {
763 scale: scales,
764 zero_point: None,
765 axis: Some(axis),
766 })
767 }
768
769 /// Per-channel asymmetric. Errors on length mismatch between `scales`
770 /// and `zero_points`, or empty arrays.
771 pub fn per_channel(scales: Vec<f32>, zero_points: Vec<i32>, axis: usize) -> Result<Self> {
772 if scales.is_empty() {
773 return Err(Error::QuantizationInvalid {
774 field: "scale.len",
775 expected: "non-empty per-channel scales".to_string(),
776 got: "length 0".to_string(),
777 });
778 }
779 if scales.len() != zero_points.len() {
780 return Err(Error::QuantizationInvalid {
781 field: "zero_point.len",
782 expected: format!("length matches scale ({})", scales.len()),
783 got: format!("length {}", zero_points.len()),
784 });
785 }
786 Ok(Self {
787 scale: scales,
788 zero_point: Some(zero_points),
789 axis: Some(axis),
790 })
791 }
792
793 /// Borrow-based dispatch view. Match once at kernel entry.
794 pub fn mode(&self) -> QuantMode<'_> {
795 match (self.scale.len(), self.zero_point.as_deref(), self.axis) {
796 (1, None, _) => QuantMode::PerTensorSymmetric {
797 scale: self.scale[0],
798 },
799 (1, Some(zps), _) => QuantMode::PerTensor {
800 scale: self.scale[0],
801 zero_point: zps.first().copied().unwrap_or(0),
802 },
803 (_, None, Some(axis)) => QuantMode::PerChannelSymmetric {
804 scales: &self.scale,
805 axis,
806 },
807 (_, Some(zps), Some(axis)) => QuantMode::PerChannel {
808 scales: &self.scale,
809 zero_points: zps,
810 axis,
811 },
812 // The `validate()` path prevents constructing a
813 // per-channel Quantization without an axis, so the remaining
814 // pattern is unreachable in practice. Fall back to
815 // per-tensor symmetric using scale[0] to avoid panicking in
816 // release; debug builds assert.
817 _ => {
818 debug_assert!(
819 false,
820 "Quantization::mode: per-channel without axis is unreachable"
821 );
822 QuantMode::PerTensorSymmetric {
823 scale: self.scale.first().copied().unwrap_or(1.0),
824 }
825 }
826 }
827 }
828
829 /// Returns `true` for per-tensor quantization (`scale.len() == 1`).
830 pub fn is_per_tensor(&self) -> bool {
831 self.scale.len() == 1
832 }
833
834 /// Returns `true` for per-channel quantization (`scale.len() > 1`).
835 pub fn is_per_channel(&self) -> bool {
836 self.scale.len() > 1
837 }
838
839 /// Returns `true` for symmetric quantization (no zero-point, or
840 /// zero-point vector of all zeros).
841 pub fn is_symmetric(&self) -> bool {
842 match &self.zero_point {
843 None => true,
844 Some(zps) => zps.iter().all(|&z| z == 0),
845 }
846 }
847
848 /// Borrow the scale array. Length 1 for per-tensor; `num_channels` for
849 /// per-channel.
850 pub fn scale(&self) -> &[f32] {
851 &self.scale
852 }
853
854 /// Borrow the zero-point array. `None` for symmetric.
855 pub fn zero_point(&self) -> Option<&[i32]> {
856 self.zero_point.as_deref()
857 }
858
859 /// Channel axis for per-channel quantization. `None` for per-tensor.
860 pub fn axis(&self) -> Option<usize> {
861 self.axis
862 }
863
864 /// Validate against a target tensor shape. Runs in
865 /// `Tensor::set_quantization()`. Catches:
866 /// - empty `scale` (reject — must declare at least one factor)
867 /// - `zero_point` length inconsistent with `scale` (reject —
868 /// per-tensor must have len 1, per-channel must match `scale.len`)
869 /// - `axis >= shape.len()` (axis out of range)
870 /// - `scale.len() != shape[axis]` for per-channel
871 /// - per-channel without axis (reject)
872 /// - per-tensor with redundant axis (reject)
873 ///
874 /// `static`-only: `Tensor::set_quantization` is `impl<T> Tensor<T>`,
875 /// itself `#[cfg(feature = "static")]`. `dynamic`'s
876 /// `TensorDyn::set_quantization` (`dynamic_backend.rs`) sends the raw
877 /// scale/zero-point/axis over FFI and lets `libedgefirst_tensor.so`
878 /// validate them on its own (`static`) side instead of calling this
879 /// method locally.
880 #[cfg(feature = "static")]
881 pub(crate) fn validate(&self, shape: &[usize]) -> Result<()> {
882 // `Quantization` is `Deserialize`, so malformed JSON like
883 // `{"scale": [], "zero_point": []}` could otherwise produce an
884 // ill-defined value that confuses `mode()` selection and the
885 // per-channel kernels' indexing.
886 if self.scale.is_empty() {
887 return Err(Error::QuantizationInvalid {
888 field: "scale.len",
889 expected: ">= 1".to_string(),
890 got: "0".to_string(),
891 });
892 }
893 if let Some(zps) = self.zero_point.as_ref() {
894 // Per-tensor: scale.len() == 1 and zero_point.len() must == 1.
895 // Per-channel: zero_point.len() must == scale.len().
896 let expected = if self.scale.len() == 1 {
897 1
898 } else {
899 self.scale.len()
900 };
901 if zps.len() != expected {
902 return Err(Error::QuantizationInvalid {
903 field: "zero_point.len",
904 expected: format!(
905 "{expected} (matching {})",
906 if self.scale.len() == 1 {
907 "per-tensor scale"
908 } else {
909 "per-channel scale.len"
910 }
911 ),
912 got: format!("length {}", zps.len()),
913 });
914 }
915 }
916
917 match (self.scale.len(), self.axis) {
918 (1, None) => Ok(()),
919 (1, Some(_)) => Err(Error::QuantizationInvalid {
920 field: "per_tensor_redundant_axis",
921 expected: "axis=None for per-tensor quantization".to_string(),
922 got: format!("axis={:?}", self.axis),
923 }),
924 (_, None) => Err(Error::QuantizationInvalid {
925 field: "per_channel_requires_axis",
926 expected: format!(
927 "axis=Some(_) for per-channel quantization (scale.len={})",
928 self.scale.len()
929 ),
930 got: "axis=None".to_string(),
931 }),
932 (n, Some(axis)) => {
933 if axis >= shape.len() {
934 return Err(Error::QuantizationInvalid {
935 field: "axis",
936 expected: format!("axis < tensor rank ({})", shape.len()),
937 got: format!("axis={axis}"),
938 });
939 }
940 if shape[axis] != n {
941 return Err(Error::QuantizationInvalid {
942 field: "scale.len",
943 expected: format!("length matches shape[{axis}] ({})", shape[axis]),
944 got: format!("length {n}"),
945 });
946 }
947 Ok(())
948 }
949 }
950 }
951}
952
953impl From<(f32, i32)> for Quantization {
954 /// Convenience construction from a `(scale, zero_point)` tuple. Matches
955 /// the legacy `QuantTuple` / `Quantization::new` calling convention so
956 /// existing `(0.1, -128).into()` sites keep working.
957 fn from((scale, zero_point): (f32, i32)) -> Self {
958 Self::per_tensor(scale, zero_point)
959 }
960}
961
962fn deserialize_scalar_or_vec_f32<'de, D: serde::Deserializer<'de>>(
963 de: D,
964) -> std::result::Result<Vec<f32>, D::Error> {
965 use serde::de::{self, Visitor};
966 struct V;
967 impl<'de> Visitor<'de> for V {
968 type Value = Vec<f32>;
969 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
970 f.write_str("f32 or array of f32")
971 }
972 fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
973 Ok(vec![v as f32])
974 }
975 #[allow(clippy::cast_possible_truncation)]
976 fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
977 Ok(vec![v as f32])
978 }
979 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
980 fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
981 Ok(vec![v as f32])
982 }
983 fn visit_seq<A: de::SeqAccess<'de>>(
984 self,
985 mut seq: A,
986 ) -> std::result::Result<Self::Value, A::Error> {
987 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
988 while let Some(x) = seq.next_element::<f32>()? {
989 out.push(x);
990 }
991 Ok(out)
992 }
993 }
994 de.deserialize_any(V)
995}
996
997fn deserialize_opt_scalar_or_vec_i32<'de, D: serde::Deserializer<'de>>(
998 de: D,
999) -> std::result::Result<Option<Vec<i32>>, D::Error> {
1000 use serde::de::{self, Visitor};
1001 struct V;
1002 impl<'de> Visitor<'de> for V {
1003 type Value = Option<Vec<i32>>;
1004 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1005 f.write_str("null, i32, or array of i32")
1006 }
1007 fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1008 Ok(None)
1009 }
1010 fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1011 Ok(None)
1012 }
1013 fn visit_some<D2: serde::Deserializer<'de>>(
1014 self,
1015 de: D2,
1016 ) -> std::result::Result<Self::Value, D2::Error> {
1017 struct Inner;
1018 impl<'de> Visitor<'de> for Inner {
1019 type Value = Vec<i32>;
1020 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1021 f.write_str("i32 or array of i32")
1022 }
1023 #[allow(clippy::cast_possible_truncation)]
1024 fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1025 Ok(vec![v as i32])
1026 }
1027 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1028 fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1029 Ok(vec![v as i32])
1030 }
1031 fn visit_seq<A: de::SeqAccess<'de>>(
1032 self,
1033 mut seq: A,
1034 ) -> std::result::Result<Self::Value, A::Error> {
1035 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1036 while let Some(x) = seq.next_element::<i32>()? {
1037 out.push(x);
1038 }
1039 Ok(out)
1040 }
1041 }
1042 de.deserialize_any(Inner).map(Some)
1043 }
1044 #[allow(clippy::cast_possible_truncation)]
1045 fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1046 Ok(Some(vec![v as i32]))
1047 }
1048 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1049 fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1050 Ok(Some(vec![v as i32]))
1051 }
1052 fn visit_seq<A: de::SeqAccess<'de>>(
1053 self,
1054 mut seq: A,
1055 ) -> std::result::Result<Self::Value, A::Error> {
1056 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1057 while let Some(x) = seq.next_element::<i32>()? {
1058 out.push(x);
1059 }
1060 Ok(Some(out))
1061 }
1062 }
1063 de.deserialize_option(V)
1064}
1065
1066/// Count of tensor maps requested beyond the buffer's declared
1067/// [`CpuAccess`] (including any CPU map of a `CpuAccess::None` buffer).
1068/// Undeclared CPU access is a pipeline smell: it forfeits layout
1069/// optimizations (write-combined mappings, tile compression) and may be
1070/// slow or refused on Android. Read via [`unplanned_cpu_access_count`];
1071/// each offending buffer also logs one warning.
1072static UNPLANNED_CPU_ACCESS: AtomicU64 = AtomicU64::new(0);
1073
1074/// Number of tensor maps that exceeded the buffer's declared
1075/// [`CpuAccess`] since process start. A pipeline that declares its CPU
1076/// access correctly holds this flat; see [`CpuAccess`] for the contract.
1077pub fn unplanned_cpu_access_count() -> u64 {
1078 UNPLANNED_CPU_ACCESS.load(Ordering::Relaxed)
1079}
1080
1081/// Record an unplanned CPU access on `identity_id`, warning once per
1082/// buffer (a steady-state pipeline maps the same buffer every frame — a
1083/// per-map warn would flood the log; repeats count silently).
1084///
1085/// `static`-only: the sole caller is `impl<T> TensorMapTrait<T> for
1086/// Tensor<T>` in `lib.rs`, itself `#[cfg(feature = "static")]`.
1087/// [`unplanned_cpu_access_count`] stays available under `dynamic` too --
1088/// it just always reads 0 there, honestly reflecting that this backend
1089/// produces no such telemetry.
1090#[cfg(feature = "static")]
1091pub(crate) fn note_unplanned_cpu_access(identity_id: u64, backend: &str, detail: &str) {
1092 UNPLANNED_CPU_ACCESS.fetch_add(1, Ordering::Relaxed);
1093 static WARNED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<u64>>> =
1094 std::sync::OnceLock::new();
1095 let warned = WARNED.get_or_init(Default::default);
1096 if warned.lock().is_ok_and(|mut s| s.insert(identity_id)) {
1097 log::warn!(
1098 "unplanned CPU access on {backend} buffer (identity {identity_id}): {detail} — \
1099 declare the intent at allocation (CpuAccess::Read/Write/ReadWrite) to make \
1100 this a planned, optimized mapping"
1101 );
1102 }
1103}
1104
1105/// Uniform guard for mutable access through a read-only map. Every
1106/// `TensorMap` backend calls this from `as_mut_slice`/`deref_mut` so a
1107/// `map_read()` misuse fails identically on all platforms instead of
1108/// passing on tolerant ones and exploding only on Android.
1109#[inline]
1110pub(crate) fn assert_map_writable(writable: bool, backend: &str) {
1111 assert!(
1112 writable,
1113 "{backend} map is read-only (obtained via map_read()/CpuAccess::Read) — \
1114 use map_mut() or map_write() for mutable access"
1115 );
1116}
1117
1118/// Declared CPU involvement for an image tensor, chosen at allocation.
1119///
1120/// The HAL assumes buffers are produced and consumed by hardware (ISP,
1121/// codec, GPU, NPU) — hardware access needs no declaration. CPU access is
1122/// the opt-in: it selects the CPU usage/mapping mode at allocation
1123/// (write-combined for `Write`, cached for `Read`) and, on Android, pins
1124/// the layout linear (vendor tile compression requires `None`).
1125///
1126/// Mapping beyond the declared access is best-effort, never silent: it
1127/// may be refused ([`Error::NotImplemented`]) or take a slow path, and it
1128/// always increments [`unplanned_cpu_access_count`] with a once-per-buffer
1129/// warning.
1130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1131pub enum CpuAccess {
1132 /// Hardware-only buffer (the default): no CPU mapping declared.
1133 /// Compression-eligible on platforms with vendor tile layouts.
1134 #[default]
1135 None,
1136 /// CPU reads (verification, CPU consumers) — cached mapping.
1137 Read,
1138 /// CPU writes (decode targets) — write-combined mapping where the
1139 /// platform supports it; reading through a `Write` map is undeclared.
1140 Write,
1141 /// CPU reads and writes — the pre-CpuAccess implicit behavior.
1142 ReadWrite,
1143}
1144
1145impl CpuAccess {
1146 /// Whether this declaration includes CPU reads.
1147 pub fn reads(self) -> bool {
1148 matches!(self, CpuAccess::Read | CpuAccess::ReadWrite)
1149 }
1150
1151 /// Whether this declaration includes CPU writes.
1152 pub fn writes(self) -> bool {
1153 matches!(self, CpuAccess::Write | CpuAccess::ReadWrite)
1154 }
1155
1156 /// Whether this declaration covers `requested` (every direction the
1157 /// request needs is declared).
1158 pub fn covers(self, requested: CpuAccess) -> bool {
1159 (!requested.reads() || self.reads()) && (!requested.writes() || self.writes())
1160 }
1161}
1162
1163/// Requested tile-compression behavior for an image allocation (set via
1164/// [`ImageDesc::with_compression`]).
1165///
1166/// Vendor GPUs store textures in proprietary compressed tile layouts
1167/// (UBWC, AFBC, PVRIC, DCC) that cut memory bandwidth; eligibility
1168/// requires a hardware-only buffer ([`CpuAccess::None`]) because CPU
1169/// mapping pins the layout linear. The request records best knowledge on
1170/// the tensor ([`Tensor::compression`]); it never changes what bytes a
1171/// consumer sees through the GPU/NPU.
1172#[non_exhaustive]
1173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1174pub enum Compression {
1175 /// Let the platform use its native scheme when the format is
1176 /// eligible; otherwise allocate linear and count the fallback
1177 /// ([`compression_fallback_count`]). The right default for pipelines
1178 /// that want the bandwidth win without portability failures.
1179 Any,
1180 /// Require one specific scheme: allocation fails with
1181 /// [`Error::InvalidArgument`] when the device's scheme differs and
1182 /// [`Error::NotImplemented`] on platforms without vendor tile
1183 /// compression. For consumers whose ABI names a layout (e.g. a
1184 /// QNN context binary declaring UBWC inputs).
1185 Scheme(CompressionScheme),
1186}
1187
1188// Declared through `ef_vocabulary!` (see `vocabulary.rs`). This vocabulary
1189// gained a wire numbering when `ef_tensor_compression` was added: the
1190// dynamic backend reads a scheme back across the C boundary, so the enum
1191// needs codes that cannot drift from the C enumerators. Hand-writing that
1192// mapping in `edgefirst-tensor-capi` was the alternative and it is strictly
1193// worse -- `#[non_exhaustive]` makes a cross-crate `match` require a
1194// wildcard arm, so a scheme added here would silently map to "linear"
1195// there. Generated from one declaration, it cannot.
1196//
1197// Code 0 is deliberately unassigned and reserved for "linear" -- the state
1198// this crate spells `Option::None`, which has no variant to carry a code of
1199// its own. The C side names it `EF_COMPRESSION_NONE`.
1200crate::ef_vocabulary! {
1201 /// Vendor tile-compression schemes the HAL recognizes and records.
1202 ///
1203 /// Unrecognized platforms record `None` (linear) — there is deliberately
1204 /// no `Unknown` variant; a scheme is only recorded when the vendor is
1205 /// positively identified.
1206 #[non_exhaustive]
1207 pub enum CompressionScheme {
1208 /// Qualcomm Adreno Universal Bandwidth Compression.
1209 Ubwc = 1, "ubwc", UBWC,
1210 /// Arm Mali/Immortalis Framebuffer Compression.
1211 Afbc = 2, "afbc", AFBC,
1212 /// Imagination PowerVR Image Compression (Google Tensor G5+).
1213 Pvric = 3, "pvric", PVRIC,
1214 /// Samsung Xclipse (AMD RDNA) Delta Color Compression.
1215 Dcc = 4, "dcc", DCC,
1216 }
1217 // `#[doc(hidden)]` for the same reason `dtype_wire` is: emission
1218 // plumbing for `const`-only consumers, not a second documented API.
1219 #[doc(hidden)]
1220 pub mod compression_wire;
1221}
1222
1223/// Count of image allocations that requested [`Compression::Any`] but
1224/// resolved to a linear layout (ineligible format/dtype, unrecognized
1225/// vendor, or a platform without vendor tile compression). Read via
1226/// [`compression_fallback_count`].
1227static COMPRESSION_FALLBACKS: AtomicU64 = AtomicU64::new(0);
1228
1229/// Number of [`Compression::Any`] requests since process start that
1230/// resolved to a linear layout instead of a vendor tile scheme. A
1231/// steady-state pipeline holds this flat after warmup; growth means an
1232/// allocation path keeps requesting compression it never gets.
1233pub fn compression_fallback_count() -> u64 {
1234 COMPRESSION_FALLBACKS.load(Ordering::Relaxed)
1235}
1236
1237/// Record a `Compression::Any` request resolving linear.
1238///
1239/// `static`-only: the sole caller is `Tensor::image_desc` (`impl<T>
1240/// Tensor<T>`), itself `#[cfg(feature = "static")]`.
1241/// [`compression_fallback_count`] stays available under `dynamic` too --
1242/// it just always reads 0 there.
1243#[cfg(feature = "static")]
1244pub(crate) fn note_compression_fallback(detail: &str) {
1245 COMPRESSION_FALLBACKS.fetch_add(1, Ordering::Relaxed);
1246 log::debug!("compression request fell back to linear: {detail}");
1247}
1248
1249/// Whether this platform can allocate `(format, dtype)` images in a
1250/// vendor tile-compressed layout. `true` requires an Android build, an
1251/// eligible format (RGBA8888 `u8`/`i8` initially), and a positively
1252/// identified GPU vendor; everywhere else the answer is `false` and
1253/// [`Compression::Any`] requests fall back to linear.
1254pub fn compression_support(format: PixelFormat, dtype: DType) -> bool {
1255 #[cfg(target_os = "android")]
1256 {
1257 crate::ahardwarebuffer_layout::compression_eligible(format, dtype)
1258 && crate::ahardwarebuffer::device_compression_scheme().is_some()
1259 }
1260 #[cfg(not(target_os = "android"))]
1261 {
1262 let _ = (format, dtype);
1263 false
1264 }
1265}
1266
1267/// Declarative image-allocation request — the full-featured front door
1268/// for image tensors ([`TensorDyn::image_desc`] and the image crate's
1269/// `ImageProcessor::create_image_desc`).
1270///
1271/// The classic constructors (`image`, `create_image`) cover the common
1272/// cases; the desc carries the optional requests — today the
1273/// [`Compression`] request — without another constructor-parameter
1274/// sweep. Fields are private and the builders consume/return by value,
1275/// so future options are non-breaking.
1276///
1277/// ```
1278/// use edgefirst_tensor::{Compression, CpuAccess, DType, ImageDesc, PixelFormat};
1279/// let desc = ImageDesc::new(640, 640, PixelFormat::Rgba, DType::U8)
1280/// .with_access(CpuAccess::None)
1281/// .with_compression(Compression::Any);
1282/// ```
1283#[derive(Debug, Clone)]
1284pub struct ImageDesc {
1285 width: usize,
1286 height: usize,
1287 format: PixelFormat,
1288 dtype: DType,
1289 memory: Option<TensorMemory>,
1290 access: CpuAccess,
1291 compression: Option<Compression>,
1292}
1293
1294impl ImageDesc {
1295 /// A new image request: auto-selected memory, [`CpuAccess::None`]
1296 /// (hardware-only), no compression request.
1297 pub fn new(width: usize, height: usize, format: PixelFormat, dtype: DType) -> Self {
1298 Self {
1299 width,
1300 height,
1301 format,
1302 dtype,
1303 memory: None,
1304 access: CpuAccess::None,
1305 compression: None,
1306 }
1307 }
1308
1309 /// Request a specific memory backing (`None` = auto-select).
1310 pub fn with_memory(mut self, memory: Option<TensorMemory>) -> Self {
1311 self.memory = memory;
1312 self
1313 }
1314
1315 /// Declare the CPU access (see [`CpuAccess`]). Any declaration other
1316 /// than `None` makes a compression request invalid.
1317 pub fn with_access(mut self, access: CpuAccess) -> Self {
1318 self.access = access;
1319 self
1320 }
1321
1322 /// Request a tile-compressed layout (see [`Compression`]).
1323 pub fn with_compression(mut self, compression: Compression) -> Self {
1324 self.compression = Some(compression);
1325 self
1326 }
1327
1328 /// Requested width in pixels.
1329 pub fn width(&self) -> usize {
1330 self.width
1331 }
1332
1333 /// Requested height in pixels.
1334 pub fn height(&self) -> usize {
1335 self.height
1336 }
1337
1338 /// Requested pixel format.
1339 pub fn format(&self) -> PixelFormat {
1340 self.format
1341 }
1342
1343 /// Requested element type.
1344 pub fn dtype(&self) -> DType {
1345 self.dtype
1346 }
1347
1348 /// Requested memory backing (`None` = auto-select).
1349 pub fn memory(&self) -> Option<TensorMemory> {
1350 self.memory
1351 }
1352
1353 /// Declared CPU access.
1354 pub fn access(&self) -> CpuAccess {
1355 self.access
1356 }
1357
1358 /// The compression request, if any.
1359 pub fn compression(&self) -> Option<Compression> {
1360 self.compression
1361 }
1362}
1363
1364/// What kind of system key an identity was derived from. Participates in the
1365/// id so that, say, inode 7 and IOSurfaceID 7 cannot collide.
1366///
1367/// Invariant every variant must uphold: the key passed to
1368/// [`BufferIdentity::derived`] is unique among live buffers of that kind
1369/// **that own an allocation**. There is no "no identity" case — where no
1370/// system-wide key exists a process-local one does (a pointer, a GL name),
1371/// and a process-local key is exactly as sound as a system-wide one:
1372/// pointer/name reuse after free is the same ABA hazard as inode reuse, and
1373/// is mitigated the same way, by the retaining cache (see the design spec's
1374/// Task 6).
1375///
1376/// The allocation qualifier is load-bearing, not a hedge. A zero-element
1377/// tensor (`[0, num_protos]`, the tracker's "no detections this frame"
1378/// sentinel — see [`crate::mem`]) owns no allocation, so its `Vec` yields the
1379/// dangling well-aligned pointer `align_of::<T>()`. Every zero-element
1380/// `Tensor<f32>` in a process therefore shares one identity, and can even
1381/// collide across kinds with a small integer key from another variant.
1382///
1383/// That is safe rather than tolerated: an identity exists to answer "are
1384/// these the same bytes?", and buffers with no bytes have nothing to alias.
1385/// Nor can they reach an import cache — both GPU import paths require a
1386/// dma-buf fd or an IOSurface ref and reject anything else, so a `HostPtr`
1387/// identity is never a cache key. Do not "fix" this by minting a distinct id
1388/// per empty tensor: that reintroduces a counter, which is the state this
1389/// whole design exists to remove.
1390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1391pub enum IdentityKind {
1392 /// `(st_dev, st_ino)` of a dma-buf or memfd. Survives `dup`, and is the
1393 /// same value in every process that holds the buffer.
1394 DmaBuf,
1395 Shm,
1396 /// `IOSurfaceGetID`.
1397 IoSurface,
1398 /// `AHardwareBuffer_getId`, API 31+.
1399 AHardwareBufferId,
1400 /// The `AHardwareBuffer*` pointer, pre-API-31 where no id exists.
1401 /// Process-local: unique among live buffers here, meaningless elsewhere.
1402 AHardwareBufferPtr,
1403 /// A host mapping address. Process-local.
1404 HostPtr,
1405 /// A GL buffer name. Valid only in the context that created it.
1406 Pbo,
1407}
1408
1409/// Unique identity for a tensor's underlying buffer.
1410///
1411/// Created fresh on every buffer allocation or import. The `id` is a cache
1412/// key, `kind` records what system key (if any) it was derived from, and the
1413/// `guard` is an `Arc<()>` whose weak references allow downstream caches to
1414/// detect when the buffer has been dropped.
1415#[derive(Debug, Clone)]
1416pub struct BufferIdentity {
1417 id: u64,
1418 kind: IdentityKind,
1419 guard: Arc<()>,
1420}
1421
1422impl BufferIdentity {
1423 /// Derive an identity from a key the operating system already guarantees.
1424 ///
1425 /// Deliberately NOT a counter: two independently-linked copies of this
1426 /// crate must derive the same id for the same buffer, or a consumer's
1427 /// import cache misses every frame. See the design spec, "The core finding".
1428 pub fn derived(kind: IdentityKind, key: u64) -> Self {
1429 // Mix the discriminant into the high bits so different kinds with the
1430 // same numeric key never collide.
1431 let id = ((kind as u64) << 56) ^ key;
1432 Self {
1433 id,
1434 kind,
1435 guard: Arc::new(()),
1436 }
1437 }
1438
1439 /// Unique identifier for this buffer. Changes when the buffer changes.
1440 pub fn id(&self) -> u64 {
1441 self.id
1442 }
1443
1444 /// What kind of system key this identity was derived from.
1445 pub fn kind(&self) -> IdentityKind {
1446 self.kind
1447 }
1448
1449 /// Returns a weak reference to the buffer guard. Goes dead when the
1450 /// owning Tensor is dropped (and no clones remain).
1451 pub fn weak(&self) -> Weak<()> {
1452 Arc::downgrade(&self.guard)
1453 }
1454}
1455
1456// `static`-only from here through `fs_magic` below: every use site lives in
1457// `impl<T> TensorStorage<T>` (`from_fd`'s DMA-BUF-vs-tmpfs classification),
1458// itself `#[cfg(feature = "static")]`. `dynamic`'s `from_fd`
1459// (`dynamic_backend.rs`) drives `ef_tensor_builder_wrap` instead, which
1460// does this same classification inside `libedgefirst_tensor.so`.
1461#[cfg(all(target_os = "linux", feature = "static"))]
1462use nix::sys::stat::{major, minor};
1463
1464/// Filesystem magic of the internal `dma_buf` mount, from
1465/// `include/uapi/linux/magic.h` (`DMA_BUF_MAGIC`, "DMAB").
1466///
1467/// This is stable UAPI and is the only reliable way to recognize a DMA-BUF
1468/// fd — see [`TensorStorage::from_fd`] for why `st_dev` cannot be used.
1469#[cfg(all(target_os = "linux", feature = "static"))]
1470const DMA_BUF_MAGIC: u32 = 0x444d_4142;
1471
1472/// Filesystem magic of tmpfs, from `include/uapi/linux/magic.h`
1473/// (`TMPFS_MAGIC`). Covers both POSIX `shm_open` segments under `/dev/shm`
1474/// and anonymous `memfd_create` files.
1475#[cfg(all(target_os = "linux", feature = "static"))]
1476const TMPFS_MAGIC: u32 = 0x0102_1994;
1477
1478/// Normalize a raw `fstatfs` `f_type` to the 32-bit unsigned magic that
1479/// `include/uapi/linux/magic.h` documents.
1480///
1481/// The width and signedness of `f_type` are target-dependent: `__fsword_t`
1482/// on Linux/gnu (`i64` on 64-bit, `i32` on 32-bit targets such as armv7,
1483/// i686 and aarch64-ilp32), `c_int` on uclibc, `c_ulong` on musl, and
1484/// `c_uint` on s390x. Where it is signed and 32 bits wide, widening it
1485/// sign-extends every magic with bit 31 set — hugetlbfs (`0x958458f6`),
1486/// btrfs (`0x9123683e`), f2fs (`0xf2f52010`) — which would make the value
1487/// reported by [`Error::UnknownBufferType`] impossible to find in the
1488/// header the docs point callers at.
1489///
1490/// Every magic is a 32-bit quantity, so truncating back to `u32` recovers
1491/// the true value from all four representations and loses nothing.
1492#[cfg(all(target_os = "linux", feature = "static"))]
1493const fn fs_magic(raw: i64) -> u32 {
1494 raw as u32
1495}
1496
1497/// The operations every memory backend implements.
1498///
1499/// This is the seam that lets [`Tensor<T>`] hide which backend is in play.
1500/// The implementors are `DmaTensor` (Linux DMA-BUF), `IoSurfaceTensor`
1501/// (macOS/iOS), `AHardwareBufferTensor` (Android), `ShmTensor`, `MemTensor`,
1502/// and [`PboTensor`]; `TensorStorage<T>` dispatches to whichever is active.
1503/// Backend types are not public — allocate a [`Tensor<T>`] or [`TensorDyn`]
1504/// and call these methods through it.
1505///
1506/// Import the trait to get `shape`, `size`, `map`, `clone_fd`,
1507/// `buffer_identity`, and the zero-copy sub-region `view` on a tensor value.
1508/// `Tensor::view` and `Tensor::batch` route through [`TensorTrait::view`], so
1509/// each backend's identity-sharing rule lives in exactly one place.
1510///
1511/// [`TensorDyn`]: crate::TensorDyn
1512pub trait TensorTrait<T>: Send + Sync
1513where
1514 T: Num + Clone + fmt::Debug,
1515{
1516 /// Create a new tensor with the given shape and optional name. If no name
1517 /// is given, a random name will be generated.
1518 fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
1519 where
1520 Self: Sized;
1521
1522 #[cfg(unix)]
1523 /// Import an existing buffer as a tensor, taking ownership of its file
1524 /// descriptor. The buffer is adopted in place — no bytes are copied.
1525 ///
1526 /// The backend is **detected**, not chosen: the fd already belongs to a
1527 /// buffer type, and this call's job is to recognize which. On Linux that
1528 /// is decided by the fd's filesystem magic, which is stable UAPI
1529 /// (`include/uapi/linux/magic.h`):
1530 ///
1531 /// | Filesystem | Magic | Resulting [`TensorMemory`] |
1532 /// |------------|-------|----------------------------|
1533 /// | `dma_buf` | `DMA_BUF_MAGIC` (`0x444d4142`) | [`TensorMemory::DmaBuf`] |
1534 /// | `tmpfs` (`/dev/shm` **and** `memfd`) | `TMPFS_MAGIC` (`0x01021994`) | [`TensorMemory::Shm`] |
1535 /// | anything else | — | *rejected* — see Errors |
1536 ///
1537 /// Both supported types are identified **positively**. An unrecognized
1538 /// filesystem is an error, never a fallback to shared memory: the wrong
1539 /// branch does not fail loudly (a DMA-BUF is mmap-able, so it would
1540 /// import as a perfectly functional tensor that merely isn't DMA, and a
1541 /// pipe would import as a zero-length one), so guessing would trade a
1542 /// clear error here for silent loss of zero-copy far downstream.
1543 ///
1544 /// The device number is deliberately **not** consulted. `dma_buf` files
1545 /// live on an internal kernel mount whose `st_dev` comes from
1546 /// `get_anon_bdev()` — an IDA shared with every other anonymous
1547 /// pseudo-filesystem and allocated in boot order — so the minor a
1548 /// DMA-BUF lands on varies by kernel build and is not part of any ABI.
1549 ///
1550 /// On non-Linux Unix (macOS/iOS/Android) there is no fd-based DMA import;
1551 /// the fd is always adopted as [`TensorMemory::Shm`].
1552 ///
1553 /// # Arguments
1554 ///
1555 /// * `fd` - Owned descriptor for the buffer to import. Ownership
1556 /// transfers to the returned tensor and the fd is closed on drop; pass
1557 /// [`clone_fd`](TensorTrait::clone_fd) output to keep your own handle.
1558 /// * `shape` - Logical shape to interpret the buffer with. Must describe
1559 /// no more elements than the buffer holds.
1560 /// * `name` - Optional name; a random one is generated when `None`.
1561 ///
1562 /// # Returns
1563 ///
1564 /// A tensor sharing the imported buffer's memory, whose
1565 /// [`memory()`](TensorTrait::memory) reports the detected backend.
1566 ///
1567 /// # Errors
1568 ///
1569 /// * [`Error::UnknownBufferType`] - the fd is on a filesystem that is
1570 /// neither `dma_buf` nor `tmpfs`, so its buffer type cannot be
1571 /// determined. Carries the observed `fstatfs` magic as a `u32`,
1572 /// normalized so it can be looked up in `magic.h` directly on both
1573 /// 32- and 64-bit targets. Typical causes: a regular file, a pipe or
1574 /// socket, or a `MFD_HUGETLB` memfd (hugetlbfs, not tmpfs). Linux only.
1575 /// * [`Error::UnknownDeviceType`] - the fd's `st_dev` major is non-zero,
1576 /// i.e. it lives on a real block device rather than an anonymous or
1577 /// in-memory filesystem. Linux only.
1578 /// * [`Error::InvalidSize`] - `shape` is empty or describes zero
1579 /// elements.
1580 /// * [`Error::NixError`] - `fstat`, `fstatfs`, or `mmap` failed on the
1581 /// descriptor.
1582 ///
1583 /// # Examples
1584 ///
1585 /// ```no_run
1586 /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
1587 ///
1588 /// # fn main() -> edgefirst_tensor::Result<()> {
1589 /// let src = Tensor::<u8>::new(&[480, 640, 3], Some(TensorMemory::DmaBuf), None)?;
1590 ///
1591 /// // Round-tripping a DMA-BUF fd preserves the backend — the import is
1592 /// // still zero-copy, and still eligible for GPU/NPU paths.
1593 /// let imported = Tensor::<u8>::from_fd(src.clone_fd()?, src.shape(), None)?;
1594 /// assert_eq!(imported.memory(), TensorMemory::DmaBuf);
1595 /// # Ok(())
1596 /// # }
1597 /// ```
1598 fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
1599 where
1600 Self: Sized;
1601
1602 #[cfg(unix)]
1603 /// Clone the file descriptor associated with this tensor.
1604 fn clone_fd(&self) -> Result<std::os::fd::OwnedFd>;
1605
1606 /// Get the memory type of this tensor.
1607 fn memory(&self) -> TensorMemory;
1608
1609 /// Get the name of this tensor.
1610 fn name(&self) -> String;
1611
1612 /// Get the number of elements in this tensor.
1613 fn len(&self) -> usize {
1614 self.shape().iter().product()
1615 }
1616
1617 /// Check if the tensor is empty.
1618 fn is_empty(&self) -> bool {
1619 self.len() == 0
1620 }
1621
1622 /// Get the size in bytes of this tensor.
1623 fn size(&self) -> usize {
1624 self.len() * std::mem::size_of::<T>()
1625 }
1626
1627 /// Get the shape of this tensor.
1628 fn shape(&self) -> &[usize];
1629
1630 /// Reshape this tensor to the given shape. The total number of elements
1631 /// must remain the same.
1632 fn reshape(&mut self, shape: &[usize]) -> Result<()>;
1633
1634 /// Bytes of the underlying allocation (>= the current logical `size()`).
1635 /// Defaults to the logical size for storages without spare capacity.
1636 fn capacity_bytes(&self) -> usize {
1637 self.size()
1638 }
1639
1640 /// Set the logical shape to any shape whose byte size fits the allocation
1641 /// capacity, without the equal-size constraint of `reshape`.
1642 fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
1643 self.reshape(shape)
1644 }
1645
1646 /// Map the tensor into memory with the given access direction and
1647 /// return a TensorMap for accessing the data.
1648 ///
1649 /// `access` selects the platform mapping mode (read-only IOSurface
1650 /// lock, dma-buf sync direction, AHardwareBuffer lock usage) and the
1651 /// map's mutability: a map obtained with [`CpuAccess::Read`] rejects
1652 /// `as_mut_slice`. [`CpuAccess::None`] is not a mappable direction
1653 /// and returns [`Error::InvalidArgument`].
1654 ///
1655 /// Prefer the typed wrappers [`map_read`](Self::map_read) /
1656 /// [`map_write`](Self::map_write) / [`map_mut`](Self::map_mut).
1657 /// Map for CPU access.
1658 ///
1659 /// Like [`pin_host`](Tensor::pin_host), `'a` bounds how long the backing
1660 /// allocation lives rather than borrowing the tensor: the returned view
1661 /// shares ownership through the pin's keepalive, so a map can coexist with
1662 /// a `&mut` on the same tensor.
1663 fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1664 where
1665 T: 'a;
1666
1667 /// Map the tensor read-write (equivalent to
1668 /// `map_with(CpuAccess::ReadWrite)` — the historical `map()`
1669 /// behavior).
1670 fn map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1671 where
1672 T: 'a,
1673 {
1674 self.map_with(CpuAccess::ReadWrite)
1675 }
1676
1677 /// Map the tensor for CPU reading only. The returned map rejects
1678 /// `as_mut_slice`; on macOS this takes the read-only IOSurface lock
1679 /// (skips the unlock flush), on Linux the dma-buf read-direction
1680 /// sync.
1681 fn map_read<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1682 where
1683 T: 'a,
1684 {
1685 self.map_with(CpuAccess::Read)
1686 }
1687
1688 /// Map the tensor for CPU writing (fill-only: reading through a
1689 /// write map may see write-combined memory — do not read the slice).
1690 fn map_write<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1691 where
1692 T: 'a,
1693 {
1694 self.map_with(CpuAccess::Write)
1695 }
1696
1697 /// Map the tensor read-write (alias of [`map`](Self::map) with the
1698 /// intent spelled out).
1699 fn map_mut<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1700 where
1701 T: 'a,
1702 {
1703 self.map_with(CpuAccess::ReadWrite)
1704 }
1705
1706 /// Get the buffer identity for cache keying and liveness tracking.
1707 fn buffer_identity(&self) -> &BufferIdentity;
1708
1709 /// Create a zero-copy sub-region view of this backing that shares the
1710 /// underlying allocation **and** [`BufferIdentity`].
1711 ///
1712 /// The window is `[offset_bytes, offset_bytes + shape.product() *
1713 /// size_of::<T>())` measured from this tensor's own logical start, so a
1714 /// sub-view of a sub-view composes by adding offsets. Sharing the parent's
1715 /// identity is the contract that lets identity-keyed caches (e.g. the GL
1716 /// EGLImage import cache) treat offset-distinct windows as one buffer rather
1717 /// than unrelated allocations — `view` must never mint a fresh identity.
1718 ///
1719 /// Defaults to [`Error::NotImplemented`]; every backend that supports
1720 /// sub-views overrides it (`Mem`, `Shm`, Linux DMA, macOS IOSurface, `Pbo`).
1721 fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self>
1722 where
1723 Self: Sized,
1724 {
1725 let _ = (offset_bytes, shape);
1726 Err(Error::NotImplemented(
1727 "view (zero-copy sub-region) is not supported for this tensor backend".to_owned(),
1728 ))
1729 }
1730}
1731
1732pub trait TensorMapTrait<T>
1733where
1734 T: Num + Clone + fmt::Debug,
1735{
1736 /// Get the shape of this tensor map.
1737 fn shape(&self) -> &[usize];
1738
1739 /// Unmap the tensor from memory.
1740 fn unmap(&mut self);
1741
1742 /// Get the number of elements in this tensor map.
1743 fn len(&self) -> usize {
1744 self.shape().iter().product()
1745 }
1746
1747 /// Check if the tensor map is empty.
1748 fn is_empty(&self) -> bool {
1749 self.len() == 0
1750 }
1751
1752 /// Get the size in bytes of this tensor map.
1753 fn size(&self) -> usize {
1754 self.len() * std::mem::size_of::<T>()
1755 }
1756
1757 /// Get a slice to the data in this tensor map.
1758 fn as_slice(&self) -> &[T];
1759
1760 /// Get a mutable slice to the data in this tensor map.
1761 fn as_mut_slice(&mut self) -> &mut [T];
1762
1763 /// Whether this mapping permits writes -- i.e. whether the coherency
1764 /// bracket it holds will flush on release.
1765 ///
1766 /// The single source of truth for a binding that has to advertise
1767 /// mutability to a foreign consumer (the Python buffer protocol's
1768 /// `readonly` flag, say). Deriving that from the access a caller
1769 /// *requested* instead would leave the advertisement agreeing with the
1770 /// request while both disagreed with the mapping actually taken.
1771 fn is_writable(&self) -> bool;
1772
1773 #[cfg(feature = "ndarray")]
1774 /// Get an ndarray ArrayView of the tensor data.
1775 fn view(&'_ self) -> Result<ndarray::ArrayView<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
1776 Ok(ndarray::ArrayView::from_shape(
1777 self.shape(),
1778 self.as_slice(),
1779 )?)
1780 }
1781
1782 #[cfg(feature = "ndarray")]
1783 /// Get an ndarray ArrayViewMut of the tensor data.
1784 fn view_mut(
1785 &'_ mut self,
1786 ) -> Result<ndarray::ArrayViewMut<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
1787 let shape = self.shape().to_vec();
1788 Ok(ndarray::ArrayViewMut::from_shape(
1789 shape,
1790 self.as_mut_slice(),
1791 )?)
1792 }
1793}
1794
1795// Declared through `ef_vocabulary!` (see `vocabulary.rs`). Before this, the
1796// same four backings were numbered three different ways -- Rust's implicit
1797// discriminants (`Dma=0, Shm=1, Mem=2, Pbo=3`), the C ABI's
1798// `hal_tensor_memory` (`MEM=0, DMA=1, SHM=2, PBO=3`), and Python's
1799// `PyTensorMemory`, which had no explicit discriminants at all and a
1800// `#[cfg(unix)]` variant in the middle of the list, so its numbering
1801// depended on the target OS. The canonical assignment below is the one all
1802// of them converge on; `hal_tensor_memory` is the single explicitly-mapped
1803// outlier that keeps its own released values (see `to_hal_tensor_memory` in
1804// the capi crate).
1805//
1806// Note this is NOT `protocol::kind`, which is a genuinely different
1807// vocabulary and stays so: `kind` describes what a `TensorDesc`'s `handle`
1808// and `ptr` fields MEAN to an importer, and both `Mem` and `Shm` mean the
1809// same thing there (`kind::HOST`, host-addressable pointer, no handle). A
1810// vocabulary that collapses two variants into one cannot also be the
1811// vocabulary that distinguishes them. `kind_of()` in `protocol.rs` is the
1812// mapping between them.
1813crate::ef_vocabulary! {
1814 /// Which memory backend a tensor is (or should be) allocated from.
1815 ///
1816 /// Pass `Some(..)` to a constructor to pin the backend, or `None` to
1817 /// auto-select. Auto-selection differs by constructor: [`Tensor::new`]
1818 /// tries `DmaBuf` → `Mem`, while the image constructors try `DmaBuf` →
1819 /// `Mem` and skip `Shm`. `EDGEFIRST_TENSOR_FORCE_MEM=1` short-circuits
1820 /// either chain to `Mem`, which is how tests run on hosts without
1821 /// DMA-heap permissions.
1822 ///
1823 /// A pinned request has no fallback: if the backend cannot serve it, the
1824 /// constructor fails rather than quietly giving you something slower.
1825 /// [`TensorTrait::memory`] reports what a tensor actually ended up with.
1826 ///
1827 /// # Every variant exists on every platform
1828 ///
1829 /// This is a namespace of codes, not a list of what this build can
1830 /// allocate -- [`TensorMemory::is_available`] answers the latter, at
1831 /// runtime. `Shm` used to be `#[cfg(unix)]`, which shifted every
1832 /// variant declared after it on a non-unix build and made the
1833 /// numbering a compile-time detail of the host. It also made a
1834 /// cross-platform recording unreadable: a blob captured on Linux
1835 /// carries `"dmabuf"`, and a macOS consumer has to be able to NAME
1836 /// that backing in order to report "cannot materialise here" instead
1837 /// of failing with an unrecognised code.
1838 ///
1839 /// # `#[non_exhaustive]`
1840 ///
1841 /// Match this with a wildcard arm. The variant list is expected to
1842 /// keep growing -- `IoSurface` and `Cuda` were added here, Windows
1843 /// backings and a real CUDA device backing are named as coming -- and
1844 /// this attribute is what keeps a closed-source consumer's
1845 /// `match memory { .. }` compiling across those additions. It protects
1846 /// DOWNSTREAM, not this crate: inside `edgefirst-tensor` the matches
1847 /// stay exhaustive and compile-enforced. Same rule as [`DType`],
1848 /// [`Compression`] and [`PixelFormat`], which `TensorMemory` was the
1849 /// lone exception to.
1850 #[non_exhaustive]
1851 pub enum TensorMemory {
1852 /// Regular system memory allocation. Available everywhere; the
1853 /// floor every auto-selection chain ends on.
1854 Mem = 0, "mem", MEM,
1855 /// POSIX Shared Memory allocation. Suitable for inter-process
1856 /// communication, but not for hardware acceleration. Only
1857 /// allocatable on unix; nameable everywhere.
1858 Shm = 1, "shm", SHM,
1859 /// Platform-native zero-copy GPU buffer.
1860 ///
1861 /// On Linux this is a DMA-BUF (`DmaTensor` in
1862 /// `crates/tensor/src/dma.rs`) allocated via the DRM/dma-heap
1863 /// subsystem. On macOS/iOS it is currently also this variant that
1864 /// yields an IOSurface, and on Android an AHardwareBuffer: they
1865 /// share the `TensorStorage::Dma` slot at the trait level, and the
1866 /// public C API discriminant (`HAL_TENSOR_MEMORY_DMA = 1`) covers
1867 /// all three with no ABI break. `IoSurface` below is the code for
1868 /// naming that backing specifically; no backend reports it yet.
1869 ///
1870 /// Allows hardware-accelerated paths (OpenGL backend on Linux via
1871 /// `EGL_EXT_image_dma_buf_import`; macOS via
1872 /// `EGL_ANGLE_iosurface_client_buffer`). CPU access via `map()`
1873 /// incurs cache-coherency overhead on Linux DMA-BUF and is similar
1874 /// in cost on IOSurface; SHM/Mem are cheaper for CPU-only workloads.
1875 DmaBuf = 2, "dmabuf", DMABUF,
1876 /// Apple IOSurface, named specifically rather than through the
1877 /// portable `DmaBuf` spelling.
1878 ///
1879 /// Defined as a code so a descriptor or recording that names an
1880 /// IOSurface is parseable on every platform. **No backend produces
1881 /// or accepts it yet** -- macOS/iOS allocate and report `DmaBuf`.
1882 /// Pinning a request to it fails with `NotImplemented`.
1883 IoSurface = 3, "iosurface", IOSURFACE,
1884 /// OpenGL Pixel Buffer Object memory. Created by `ImageProcessor`
1885 /// when DMA-buf is unavailable but OpenGL is present -- never by
1886 /// [`Tensor::new`], which has no GL context to create it in.
1887 Pbo = 4, "pbo", PBO,
1888 /// CUDA device memory: a device pointer, not host-addressable.
1889 ///
1890 /// Defined as a code for the same reason as `IoSurface`. **No
1891 /// backend produces or accepts it yet**; pinning a request to it
1892 /// fails with `NotImplemented`.
1893 Cuda = 5, "cuda", CUDA,
1894 }
1895 // `#[doc(hidden)]`: `pub` so the const-only form is reachable by an FFI
1896 // or cbindgen consumer, but it is emission plumbing rather than a second
1897 // documented API -- `TensorMemory::code()` is the documented path, and
1898 // the two are generated from the same literal.
1899 #[doc(hidden)]
1900 pub mod tensor_memory_wire;
1901}
1902
1903impl TensorMemory {
1904 /// Whether this backing can actually be allocated in this process, now.
1905 ///
1906 /// A runtime question, not a compile-time one, which is why it is a
1907 /// method on the vocabulary rather than a `cfg` on the variant:
1908 /// `DmaBuf` needs `/dev/dma_heap` to exist *and* this process to have
1909 /// permission on it (a container, a user outside the `video`/`render`
1910 /// groups, and a stock desktop give three different answers on the same
1911 /// kernel), and `Shm` needs a writable `/dev/shm`. Each probe allocates
1912 /// once and caches.
1913 ///
1914 /// Reports what can be *allocated*, which is not the same as what can be
1915 /// *named*: every variant is nameable on every platform (that is the
1916 /// point of the vocabulary), and the ones this build cannot serve say so
1917 /// here rather than by being absent from the enum.
1918 ///
1919 /// Three variants answer `false` unconditionally today, for reasons
1920 /// worth stating rather than hiding behind a probe that cannot fail:
1921 ///
1922 /// * `IoSurface` -- no backend produces or accepts it yet; macOS/iOS
1923 /// serve IOSurface under the `DmaBuf` spelling, so `DmaBuf` is the
1924 /// variant that answers `true` there.
1925 /// * `Pbo` -- PBO tensors are created by `ImageProcessor::create_image`
1926 /// with a GL context current, never by [`Tensor::new`]. This crate
1927 /// holds no GL context and cannot see one; ask the image crate
1928 /// whether a GL backend is live.
1929 /// * `Cuda` -- there is no CUDA backing in this crate at all. Probing
1930 /// for `libcuda` would answer `true` on a Jetson while every
1931 /// allocation still failed, which is worse than answering `false`.
1932 pub fn is_available(self) -> bool {
1933 match self {
1934 // Always: the fallback every constructor chain ends on. If this
1935 // could fail, allocation would have no floor left.
1936 TensorMemory::Mem => true,
1937 TensorMemory::Shm => is_shm_available(),
1938 // Today `DmaBuf` is the portable spelling of "platform-native
1939 // zero-copy GPU buffer", so it is the portable probe that
1940 // answers for it -- true on macOS/iOS (IOSurface) and Android
1941 // (AHardwareBuffer) as well as Linux.
1942 TensorMemory::DmaBuf => is_gpu_buffer_available(),
1943 TensorMemory::IoSurface | TensorMemory::Pbo | TensorMemory::Cuda => false,
1944 }
1945 }
1946
1947 /// The error to return when a caller pins a request to a backing this
1948 /// build knows the name of but cannot serve.
1949 ///
1950 /// Deliberately not `unreachable!()`. `IoSurface` and `Cuda` are
1951 /// unreachable *today* only because no backend reports them yet; the
1952 /// day one does, an `unreachable!()` written now becomes a panic in
1953 /// library code reached from an ordinary caller request. A "this build
1954 /// cannot do that" error is the same information without the crash, and
1955 /// it is what every other unservable request here already returns.
1956 ///
1957 /// `static`-only: every call site is inside `impl<T> TensorStorage<T>`
1958 /// (`TensorStorage::new`), itself `#[cfg(feature = "static")]`.
1959 #[cfg(feature = "static")]
1960 pub(crate) fn unsupported_here(self) -> Error {
1961 let reason = match self {
1962 // Not reachable: `Mem` is servable on every target. Stated
1963 // rather than panicked on, for the reason above.
1964 TensorMemory::Mem => "Mem is servable on every platform, so this should not happen",
1965 TensorMemory::Shm => "POSIX shared memory is unix-only",
1966 TensorMemory::DmaBuf => {
1967 "this target has no platform GPU-buffer allocator (DMA-BUF is Linux, \
1968 IOSurface macOS/iOS, AHardwareBuffer Android)"
1969 }
1970 TensorMemory::IoSurface => {
1971 "no backend produces or accepts IOSurface under its own code yet -- \
1972 macOS/iOS allocate and report TensorMemory::DmaBuf"
1973 }
1974 TensorMemory::Pbo => {
1975 "PBO tensors are created by ImageProcessor::create_image with a GL \
1976 context current, never by Tensor::new"
1977 }
1978 TensorMemory::Cuda => "there is no CUDA backing in this build",
1979 };
1980 Error::NotImplemented(format!(
1981 "TensorMemory::{self:?} is not supported by this build: {reason}"
1982 ))
1983 }
1984}
1985
1986impl From<TensorMemory> for String {
1987 /// The wire string, via [`TensorMemory::as_str`] -- one spelling, not a
1988 /// second one maintained alongside it.
1989 ///
1990 /// `DmaBuf` renders as `"dmabuf"`, not the `"dma"` this impl used to
1991 /// emit. `TryFrom<&str>` still accepts `"dma"` so anything that
1992 /// persisted the old spelling keeps parsing; the benchmark result keys
1993 /// in `.github/scripts` spell it `"dma"` but build that string
1994 /// themselves and never went through this impl.
1995 fn from(memory: TensorMemory) -> Self {
1996 memory.as_str().to_owned()
1997 }
1998}
1999
2000impl TryFrom<&str> for TensorMemory {
2001 type Error = Error;
2002
2003 /// Parses [`TensorMemory::as_str`]'s spelling, plus the legacy `"dma"`
2004 /// alias for `DmaBuf`.
2005 fn try_from(s: &str) -> Result<Self> {
2006 TensorMemory::from_str_code(s)
2007 .or(if s == "dma" {
2008 Some(TensorMemory::DmaBuf)
2009 } else {
2010 None
2011 })
2012 .ok_or_else(|| Error::InvalidMemoryType(s.to_owned()))
2013 }
2014}
2015
2016// Everything from here through the end of `impl<T> TensorTrait<T> for
2017// Tensor<T>` below is the `static` backend's storage-owning `Tensor<T>`:
2018// `TensorStorage<T>` (the per-memory-backend enum) and the `Tensor<T>`
2019// struct that wraps it. `dynamic`'s `Tensor<T>` is a different, much
2020// smaller type -- a typed lens over `TensorDyn` with no storage of its
2021// own -- defined in `tensor_dyn/dynamic_tensor.rs` and re-exported below.
2022#[cfg(feature = "static")]
2023#[derive(Debug)]
2024#[allow(dead_code)] // Variants are constructed by downstream crates via pub(crate) helpers
2025pub(crate) enum TensorStorage<T>
2026where
2027 T: Num + Clone + fmt::Debug + Send + Sync,
2028{
2029 /// Platform-native zero-copy GPU buffer. Inner type differs per
2030 /// target: `DmaTensor` on Linux (DMA-BUF fd), `IoSurfaceTensor` on
2031 /// macOS (CFRetained IOSurface). The shared variant name keeps the
2032 /// public `TensorMemory::DmaBuf` discriminant stable across platforms.
2033 #[cfg(target_os = "linux")]
2034 Dma(DmaTensor<T>),
2035 #[cfg(any(target_os = "macos", target_os = "ios"))]
2036 Dma(IoSurfaceTensor<T>),
2037 #[cfg(target_os = "android")]
2038 Dma(AHardwareBufferTensor<T>),
2039 #[cfg(unix)]
2040 Shm(ShmTensor<T>),
2041 Mem(MemTensor<T>),
2042 Pbo(PboTensor<T>),
2043}
2044
2045#[cfg(feature = "static")]
2046impl<T> TensorStorage<T>
2047where
2048 T: Num + Clone + fmt::Debug + Send + Sync,
2049{
2050 /// The backing allocation's intrinsic physical row pitch in bytes, if it
2051 /// has one that is fixed independent of the logical shape. macOS IOSurface
2052 /// reports its 64-aligned `bytesPerRow`; other backings (Linux DMA, SHM,
2053 /// Mem, PBO) have no fixed pitch beyond the logical shape and return `None`.
2054 ///
2055 /// Used by `configure_image` to preserve the physical pitch when a reused
2056 /// pool tensor is reconfigured to a smaller logical image — so the decode
2057 /// writes rows at the surface's real stride and the GPU samples them with
2058 /// the same stride (the physical-grid / logical-ROI decoupling).
2059 pub(crate) fn backing_row_stride(&self) -> Option<usize> {
2060 match self {
2061 // Only genuine image-formatted IOSurfaces (height > 1) carry a real
2062 // per-row pitch; a generic byte-bag (height == 1) returns `None` so
2063 // `configure_image` does not adopt its whole-buffer "row" as a stride.
2064 #[cfg(any(target_os = "macos", target_os = "ios"))]
2065 TensorStorage::Dma(t) => t.image_backing_row_stride(),
2066 // Android AHardwareBuffer: same rule — only genuine 2D
2067 // image-formatted buffers (height > 1) carry a real row pitch.
2068 #[cfg(target_os = "android")]
2069 TensorStorage::Dma(t) => t.image_backing_row_stride(),
2070 _ => None,
2071 }
2072 }
2073
2074 /// Create a new tensor storage with the given shape, memory type, and
2075 /// optional name. If no name is given, a random name will be generated.
2076 /// If no memory type is given, the best available memory type will be
2077 /// chosen based on the platform and environment variables.
2078 fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
2079 match memory {
2080 #[cfg(target_os = "linux")]
2081 Some(TensorMemory::DmaBuf) => {
2082 DmaTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2083 }
2084 #[cfg(any(target_os = "macos", target_os = "ios"))]
2085 Some(TensorMemory::DmaBuf) => {
2086 IoSurfaceTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2087 }
2088 #[cfg(target_os = "android")]
2089 Some(TensorMemory::DmaBuf) => {
2090 AHardwareBufferTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2091 }
2092 #[cfg(not(any(
2093 target_os = "linux",
2094 target_os = "macos",
2095 target_os = "ios",
2096 target_os = "android"
2097 )))]
2098 Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2099 "TensorMemory::DmaBuf is only available on Linux (DMA-BUF), macOS/iOS (IOSurface), \
2100 and Android (AHardwareBuffer)"
2101 .to_owned(),
2102 )),
2103 #[cfg(unix)]
2104 Some(TensorMemory::Shm) => {
2105 ShmTensor::<T>::new(shape, name).map(TensorStorage::Shm)
2106 }
2107 #[cfg(not(unix))]
2108 Some(m @ TensorMemory::Shm) => Err(m.unsupported_here()),
2109 Some(TensorMemory::Mem) => {
2110 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2111 }
2112 Some(TensorMemory::Pbo) => Err(crate::error::Error::NotImplemented(
2113 "PboTensor cannot be created via Tensor::new() — use ImageProcessor::create_image()".to_owned(),
2114 )),
2115 // Defined codes with no backing behind them yet. An error, not
2116 // an `unreachable!()` -- see `unsupported_here`.
2117 Some(m @ (TensorMemory::IoSurface | TensorMemory::Cuda)) => Err(m.unsupported_here()),
2118 None => {
2119 if std::env::var("EDGEFIRST_TENSOR_FORCE_MEM")
2120 .is_ok_and(|x| x != "0" && x.to_lowercase() != "false")
2121 {
2122 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2123 } else {
2124 // Auto-select priority: Dma > Mem. Shm is intentionally NOT
2125 // auto-selected — it offers no advantage over Mem for an
2126 // in-process tensor and Mem always succeeds, so Shm below Mem
2127 // is effectively never reached. Request Shm explicitly via
2128 // `TensorMemory::Shm` when cross-process sharing is needed.
2129 // (PBO sits between Dma and Mem but is GL-backed and created
2130 // only via `ImageProcessor::create_image`.)
2131 #[cfg(target_os = "linux")]
2132 {
2133 // Linux: Try DMA -> Mem
2134 match DmaTensor::<T>::new(shape, name) {
2135 Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2136 Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2137 }
2138 }
2139 #[cfg(any(target_os = "macos", target_os = "ios"))]
2140 {
2141 // macOS/iOS: Try IOSurface -> Mem. IOSurface is the
2142 // GPU-shareable backend (zero-copy via ANGLE), filling the
2143 // same role as DMA-BUF on Linux.
2144 match IoSurfaceTensor::<T>::new(shape, name) {
2145 Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2146 Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2147 }
2148 }
2149 #[cfg(target_os = "android")]
2150 {
2151 // Android: Mem. Unlike macOS (where a byte-bag
2152 // IOSurface is still GL-importable as R8) a generic
2153 // BLOB AHardwareBuffer cannot back a GPU texture, so
2154 // auto-selecting it buys nothing and costs plenty: a
2155 // gralloc allocator-HAL ioctl per allocation (orders
2156 // slower than malloc, ≥1 page + a dmabuf fd even for
2157 // tiny tensors) and a lock/unlock cache-maintenance
2158 // round trip on every map(). Zero-copy image tensors
2159 // come from `Tensor::image(..)`; callers that want a
2160 // BLOB (NNAPI handoff) request `TensorMemory::DmaBuf`
2161 // explicitly.
2162 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2163 }
2164 #[cfg(all(
2165 unix,
2166 not(any(
2167 target_os = "linux",
2168 target_os = "macos",
2169 target_os = "ios",
2170 target_os = "android"
2171 ))
2172 ))]
2173 {
2174 // Other Unix (BSD): Mem only (no DMA; Shm is explicit-only)
2175 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2176 }
2177 #[cfg(not(unix))]
2178 {
2179 // Windows/other: Mem only
2180 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2181 }
2182 }
2183 }
2184 }
2185 }
2186
2187 /// Create a DMA-backed tensor storage with an explicit byte size that
2188 /// may exceed `shape.product() * sizeof(T)`. Used for image tensors
2189 /// with row-padded layouts (see `DmaTensor::new_with_byte_size`).
2190 ///
2191 /// This is intentionally DMA-only: padding is only meaningful for
2192 /// buffers that will be imported as GPU textures via EGLImage. PBO,
2193 /// Shm, and Mem storage doesn't benefit from pitch alignment and
2194 /// shouldn't pay the memory cost.
2195 #[cfg(target_os = "linux")]
2196 pub(crate) fn new_dma_with_byte_size(
2197 shape: &[usize],
2198 byte_size: usize,
2199 name: Option<&str>,
2200 ) -> Result<Self> {
2201 DmaTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Dma)
2202 }
2203
2204 // No non-Linux stub: the only caller (`Tensor::image_with_stride`)
2205 // returns `NotImplemented` directly on non-Linux without ever
2206 // reaching the storage layer, so defining a stub here would be
2207 // dead code and fail the `-D warnings` clippy gate on macOS CI.
2208
2209 /// Create a Mem-backed tensor storage with an explicit byte size that may
2210 /// exceed `shape.product() * sizeof(T)`. Used for image tensors with
2211 /// 64-byte-aligned row strides (see `MemTensor::with_capacity_bytes`).
2212 pub(crate) fn new_mem_with_byte_size(
2213 shape: &[usize],
2214 byte_size: usize,
2215 name: Option<&str>,
2216 ) -> Result<Self>
2217 where
2218 T: 'static,
2219 {
2220 MemTensor::<T>::with_capacity_bytes(shape, byte_size, name).map(TensorStorage::Mem)
2221 }
2222
2223 /// Create a Shm-backed tensor storage with an explicit byte size that may
2224 /// exceed `shape.product() * sizeof(T)`. Used for image tensors with
2225 /// 64-byte-aligned row strides (see `ShmTensor::new_with_byte_size`).
2226 #[cfg(unix)]
2227 pub(crate) fn new_shm_with_byte_size(
2228 shape: &[usize],
2229 byte_size: usize,
2230 name: Option<&str>,
2231 ) -> Result<Self> {
2232 ShmTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Shm)
2233 }
2234
2235 /// Allocate an image-formatted IOSurface-backed storage (macOS).
2236 ///
2237 /// Used by `Tensor::image()` when the caller requests
2238 /// `TensorMemory::DmaBuf` and the format has an IOSurface FourCC
2239 /// mapping (YUYV, RGBA, BGRA today). Falls back to `new_with_byte_size`
2240 /// otherwise.
2241 #[cfg(any(target_os = "macos", target_os = "ios"))]
2242 pub(crate) fn new_image_iosurface(
2243 width: usize,
2244 height: usize,
2245 format: PixelFormat,
2246 dtype: DType,
2247 shape: &[usize],
2248 name: Option<&str>,
2249 ) -> Result<Self> {
2250 IoSurfaceTensor::<T>::new_image(width, height, format, dtype, shape, name)
2251 .map(TensorStorage::Dma)
2252 }
2253
2254 /// Allocate an image-formatted AHardwareBuffer-backed storage (Android).
2255 ///
2256 /// Used by `Tensor::image()` when the caller requests
2257 /// `TensorMemory::DmaBuf` and the format has an AHardwareBuffer format
2258 /// mapping (RGBA8 and the RGBA16F float paths today). Falls back to
2259 /// `new_with_byte_size` otherwise.
2260 #[cfg(target_os = "android")]
2261 pub(crate) fn new_image_ahardwarebuffer(
2262 width: usize,
2263 height: usize,
2264 format: PixelFormat,
2265 dtype: DType,
2266 shape: &[usize],
2267 name: Option<&str>,
2268 access: CpuAccess,
2269 ) -> Result<Self> {
2270 AHardwareBufferTensor::<T>::new_image(width, height, format, dtype, shape, name, access)
2271 .map(TensorStorage::Dma)
2272 }
2273
2274 /// Create a new tensor storage using the given file descriptor, shape,
2275 /// and optional name.
2276 #[cfg(unix)]
2277 fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2278 #[cfg(target_os = "linux")]
2279 {
2280 use nix::sys::stat::fstat;
2281 use nix::sys::statfs::fstatfs;
2282
2283 let stat = fstat(&fd)?;
2284 let major = major(stat.st_dev);
2285 let minor = minor(stat.st_dev);
2286
2287 if major != 0 {
2288 // Dma and Shm tensors are expected to have major number 0
2289 return Err(Error::UnknownDeviceType(major, minor));
2290 }
2291
2292 // Classify by filesystem magic, never by the st_dev minor.
2293 //
2294 // dma_buf files live on an internal kernel mount (`dma_buf_mnt`,
2295 // created with `kern_mount`) whose superblock draws its device
2296 // number from `get_anon_bdev()` — an IDA allocated first-come,
2297 // first-served during boot, shared with every other anonymous
2298 // pseudo-filesystem (pipefs, sockfs, anon_inodefs, nsfs, bdev,
2299 // tracefs, ...). The minor a DMA-BUF lands on is therefore a
2300 // function of which pseudo-filesystems registered first on that
2301 // kernel build: it moves with kernel config, driver load order,
2302 // initramfs use, and kernel version. It is not part of any ABI.
2303 //
2304 // Observed values for a genuine DMA-BUF: 12 on x86 desktop, 8 on
2305 // the ADIS Verdin. An earlier revision hardcoded `9 | 10` here,
2306 // which silently imported real DMA-BUFs as shared memory
2307 // everywhere else.
2308 //
2309 // The filesystem magic, by contrast, is stable UAPI
2310 // (include/uapi/linux/magic.h). Both branches are identified
2311 // positively; anything else is genuinely unknown and is rejected
2312 // rather than guessed at. Falling back to SHM would "work" — a
2313 // DMA-BUF is mmap-able, and even a pipe imports as a zero-length
2314 // tensor — which is exactly why it must not be the default.
2315 let magic = fs_magic(fstatfs(&fd)?.filesystem_type().0 as i64);
2316
2317 log::debug!(
2318 "Creating tensor from fd: major={major}, minor={minor}, magic={magic:#010x}"
2319 );
2320
2321 match magic {
2322 DMA_BUF_MAGIC => DmaTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Dma),
2323 TMPFS_MAGIC => ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm),
2324 other => Err(Error::UnknownBufferType(other)),
2325 }
2326 }
2327 #[cfg(all(unix, not(target_os = "linux")))]
2328 {
2329 // On macOS/iOS/BSD, always use SHM (no DMA-BUF fd import)
2330 ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm)
2331 }
2332 }
2333}
2334
2335#[cfg(feature = "static")]
2336impl<T> TensorTrait<T> for TensorStorage<T>
2337where
2338 T: Num + Clone + fmt::Debug + Send + Sync,
2339{
2340 fn new(shape: &[usize], name: Option<&str>) -> Result<Self> {
2341 Self::new(shape, None, name)
2342 }
2343
2344 #[cfg(unix)]
2345 fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2346 Self::from_fd(fd, shape, name)
2347 }
2348
2349 #[cfg(unix)]
2350 fn clone_fd(&self) -> Result<OwnedFd> {
2351 match self {
2352 #[cfg(any(
2353 target_os = "linux",
2354 target_os = "macos",
2355 target_os = "ios",
2356 target_os = "android"
2357 ))]
2358 TensorStorage::Dma(t) => t.clone_fd(),
2359 TensorStorage::Shm(t) => t.clone_fd(),
2360 TensorStorage::Mem(t) => t.clone_fd(),
2361 TensorStorage::Pbo(t) => t.clone_fd(),
2362 }
2363 }
2364
2365 fn memory(&self) -> TensorMemory {
2366 match self {
2367 #[cfg(any(
2368 target_os = "linux",
2369 target_os = "macos",
2370 target_os = "ios",
2371 target_os = "android"
2372 ))]
2373 TensorStorage::Dma(_) => TensorMemory::DmaBuf,
2374 #[cfg(unix)]
2375 TensorStorage::Shm(_) => TensorMemory::Shm,
2376 TensorStorage::Mem(_) => TensorMemory::Mem,
2377 TensorStorage::Pbo(_) => TensorMemory::Pbo,
2378 }
2379 }
2380
2381 fn name(&self) -> String {
2382 match self {
2383 #[cfg(any(
2384 target_os = "linux",
2385 target_os = "macos",
2386 target_os = "ios",
2387 target_os = "android"
2388 ))]
2389 TensorStorage::Dma(t) => t.name(),
2390 #[cfg(unix)]
2391 TensorStorage::Shm(t) => t.name(),
2392 TensorStorage::Mem(t) => t.name(),
2393 TensorStorage::Pbo(t) => t.name(),
2394 }
2395 }
2396
2397 fn shape(&self) -> &[usize] {
2398 match self {
2399 #[cfg(any(
2400 target_os = "linux",
2401 target_os = "macos",
2402 target_os = "ios",
2403 target_os = "android"
2404 ))]
2405 TensorStorage::Dma(t) => t.shape(),
2406 #[cfg(unix)]
2407 TensorStorage::Shm(t) => t.shape(),
2408 TensorStorage::Mem(t) => t.shape(),
2409 TensorStorage::Pbo(t) => t.shape(),
2410 }
2411 }
2412
2413 fn reshape(&mut self, shape: &[usize]) -> Result<()> {
2414 match self {
2415 #[cfg(any(
2416 target_os = "linux",
2417 target_os = "macos",
2418 target_os = "ios",
2419 target_os = "android"
2420 ))]
2421 TensorStorage::Dma(t) => t.reshape(shape),
2422 #[cfg(unix)]
2423 TensorStorage::Shm(t) => t.reshape(shape),
2424 TensorStorage::Mem(t) => t.reshape(shape),
2425 TensorStorage::Pbo(t) => t.reshape(shape),
2426 }
2427 }
2428
2429 fn capacity_bytes(&self) -> usize {
2430 match self {
2431 #[cfg(any(
2432 target_os = "linux",
2433 target_os = "macos",
2434 target_os = "ios",
2435 target_os = "android"
2436 ))]
2437 TensorStorage::Dma(t) => t.capacity_bytes(),
2438 #[cfg(unix)]
2439 TensorStorage::Shm(t) => t.capacity_bytes(),
2440 TensorStorage::Mem(t) => t.capacity_bytes(),
2441 TensorStorage::Pbo(t) => t.capacity_bytes(),
2442 }
2443 }
2444
2445 fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
2446 match self {
2447 #[cfg(any(
2448 target_os = "linux",
2449 target_os = "macos",
2450 target_os = "ios",
2451 target_os = "android"
2452 ))]
2453 TensorStorage::Dma(t) => t.set_logical_shape(shape),
2454 #[cfg(unix)]
2455 TensorStorage::Shm(t) => t.set_logical_shape(shape),
2456 TensorStorage::Mem(t) => t.set_logical_shape(shape),
2457 TensorStorage::Pbo(t) => t.set_logical_shape(shape),
2458 }
2459 }
2460
2461 fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2462 where
2463 T: 'a,
2464 {
2465 match self {
2466 #[cfg(any(
2467 target_os = "linux",
2468 target_os = "macos",
2469 target_os = "ios",
2470 target_os = "android"
2471 ))]
2472 TensorStorage::Dma(t) => t.map_with(access),
2473 #[cfg(unix)]
2474 TensorStorage::Shm(t) => t.map_with(access),
2475 TensorStorage::Mem(t) => t.map_with(access),
2476 TensorStorage::Pbo(t) => t.map_with(access),
2477 }
2478 }
2479
2480 fn buffer_identity(&self) -> &BufferIdentity {
2481 match self {
2482 #[cfg(any(
2483 target_os = "linux",
2484 target_os = "macos",
2485 target_os = "ios",
2486 target_os = "android"
2487 ))]
2488 TensorStorage::Dma(t) => t.buffer_identity(),
2489 #[cfg(unix)]
2490 TensorStorage::Shm(t) => t.buffer_identity(),
2491 TensorStorage::Mem(t) => t.buffer_identity(),
2492 TensorStorage::Pbo(t) => t.buffer_identity(),
2493 }
2494 }
2495
2496 /// Forward a sub-region view to the active backend, re-wrapping the
2497 /// backend's view (which shares the parent's allocation and
2498 /// [`BufferIdentity`]) back into the matching `TensorStorage` variant. Each
2499 /// backend's `view` is its own `TensorTrait::view` override; this single
2500 /// match is the only per-variant dispatch (`Tensor::subview` calls through
2501 /// here rather than matching the storage itself).
2502 fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self> {
2503 match self {
2504 #[cfg(any(
2505 target_os = "linux",
2506 target_os = "macos",
2507 target_os = "ios",
2508 target_os = "android"
2509 ))]
2510 TensorStorage::Dma(t) => t.view(offset_bytes, shape).map(TensorStorage::Dma),
2511 #[cfg(unix)]
2512 TensorStorage::Shm(t) => t.view(offset_bytes, shape).map(TensorStorage::Shm),
2513 TensorStorage::Mem(t) => t.view(offset_bytes, shape).map(TensorStorage::Mem),
2514 TensorStorage::Pbo(t) => t.view(offset_bytes, shape).map(TensorStorage::Pbo),
2515 }
2516 }
2517}
2518
2519/// Multi-backend tensor with optional image format metadata.
2520///
2521/// When `format` is `Some`, this tensor represents an image. Width, height,
2522/// and channels are derived from `shape` + `format`. When `format` is `None`,
2523/// this is a raw tensor (identical to the pre-refactoring behavior).
2524///
2525/// This is the `static` backend's `Tensor<T>` -- it owns its storage. See
2526/// `tensor_dyn/dynamic_tensor.rs` for `dynamic`'s `Tensor<T>`, a typed lens
2527/// with no storage of its own.
2528#[cfg(feature = "static")]
2529#[derive(Debug)]
2530pub struct Tensor<T>
2531where
2532 T: Num + Clone + fmt::Debug + Send + Sync,
2533{
2534 /// CUDA registration for this tensor, if any. Set after creation by
2535 /// the image crate once a PBO is registered with CUDA interop.
2536 ///
2537 /// MUST be declared before `storage`: CUDA must unregister the GL buffer
2538 /// before storage's Drop deletes it (cudaGraphicsUnregisterResource before
2539 /// glDeleteBuffers). Rust drops fields in declaration order.
2540 cuda: Option<crate::cuda::CudaHandle>,
2541 pub(crate) storage: TensorStorage<T>,
2542 format: Option<PixelFormat>,
2543 chroma: Option<Box<Tensor<T>>>,
2544 /// Row stride in bytes for externally allocated buffers with row padding.
2545 /// `None` means tightly packed (stride == width * bytes_per_pixel).
2546 row_stride: Option<usize>,
2547 /// Byte offset within the DMA-BUF where image data starts.
2548 /// `None` means offset 0 (data starts at the beginning of the buffer).
2549 plane_offset: Option<usize>,
2550 /// Quantization metadata for integer-typed tensors. Public access is
2551 /// gated by the `IntegerType` trait — `Tensor<f32>` etc. carry the
2552 /// field for layout uniformity but have no way to read or write it.
2553 pub(crate) quantization: Option<Quantization>,
2554 /// Optional colorimetry metadata. `None` = undefined; never auto-filled.
2555 colorimetry: Option<crate::Colorimetry>,
2556 /// Declared CPU access (see [`CpuAccess`]). Image constructors set it
2557 /// from their `access` parameter; non-image tensors (`new`, imports,
2558 /// numpy) default to `ReadWrite` — they are CPU-centric by nature.
2559 /// `map_with` counts requests beyond this declaration as unplanned.
2560 cpu_access: CpuAccess,
2561 /// Recorded vendor tile-compression scheme — best knowledge from
2562 /// allocation time (see [`Compression`]). `Some` only for Android
2563 /// hardware-only AHardwareBuffers whose allocation requested
2564 /// compression on an eligible format with a recognized vendor. When
2565 /// set, the row-stride accessors describe no meaningful linear
2566 /// layout. `configure_image` preserves it (physical layout, unlike
2567 /// colorimetry); views inherit it.
2568 compression: Option<CompressionScheme>,
2569 /// Parent-image snapshot when this tensor is a [`view`](Self::view)/
2570 /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. Lets the GL
2571 /// backend key its import on the parent and render the view as a
2572 /// `glViewport`/`glScissor` ROI. See [`ViewOrigin`].
2573 view_origin: Option<ViewOrigin>,
2574}
2575
2576#[cfg(feature = "static")]
2577impl<T> Tensor<T>
2578where
2579 T: Num + Clone + fmt::Debug + Send + Sync,
2580{
2581 /// Wrap a TensorStorage in a Tensor with no image metadata.
2582 pub(crate) fn wrap(storage: TensorStorage<T>) -> Self {
2583 Self {
2584 storage,
2585 format: None,
2586 chroma: None,
2587 row_stride: None,
2588 plane_offset: None,
2589 quantization: None,
2590 cuda: None,
2591 colorimetry: None,
2592 cpu_access: CpuAccess::ReadWrite,
2593 compression: None,
2594 view_origin: None,
2595 }
2596 }
2597
2598 /// Construct a tensor from a row-major element slice + shape. Allocates a
2599 /// new buffer (`TensorMemory::Mem`) and memcpys the contents; caller
2600 /// retains ownership of the input slice.
2601 ///
2602 /// # Errors
2603 ///
2604 /// - [`Error::InvalidShape`] if `values.len() != shape.iter().product()`.
2605 /// - Propagates any allocation error from [`Self::new`].
2606 pub fn from_slice(values: &[T], shape: &[usize]) -> Result<Self>
2607 where
2608 T: Copy,
2609 {
2610 let expected: usize = shape.iter().product();
2611 if values.len() != expected {
2612 return Err(Error::InvalidShape(format!(
2613 "from_slice: values.len()={} but shape product={expected} (shape={shape:?})",
2614 values.len()
2615 )));
2616 }
2617 let t = Self::new(shape, Some(TensorMemory::Mem), None)?;
2618 {
2619 let mut m = t.map()?;
2620 m.as_mut_slice().copy_from_slice(values);
2621 }
2622 Ok(t)
2623 }
2624
2625 /// Wrap externally-owned memory as a tensor without copying. The tensor
2626 /// borrows `[ptr, ptr + shape.product() * size_of::<T>())` as
2627 /// [`TensorMemory::Mem`]; `owner`, when `Some`, co-owns the source so it
2628 /// outlives the tensor (and all derived views/maps). See [`ForeignOwner`].
2629 ///
2630 /// The canonical use is CUDA zero-copy: allocate host-coherent memory
2631 /// (`cudaHostAlloc`), wrap the host pointer here, and bind the matching
2632 /// device pointer to the inference engine — reads and writes hit the same
2633 /// physical buffer with no host copy. The identical primitive backs the
2634 /// Python `Tensor.from_numpy` zero-copy borrow (owner = the NumPy object).
2635 ///
2636 /// # Safety
2637 ///
2638 /// `ptr` must be non-null, aligned to `align_of::<T>()`, and valid for
2639 /// `shape.product()` elements of `T` for as long as the returned tensor —
2640 /// and every view/map sharing its backing — is alive. Pass an `owner` that
2641 /// co-owns the source to uphold that contract.
2642 ///
2643 /// # Errors
2644 ///
2645 /// [`Error::InvalidSize`] if `shape` is empty.
2646 pub unsafe fn from_foreign(
2647 ptr: *mut T,
2648 shape: &[usize],
2649 owner: Option<crate::ForeignOwner>,
2650 name: Option<&str>,
2651 ) -> Result<Self> {
2652 // SAFETY: forwarded to `from_foreign_with_capacity`'s contract; `0`
2653 // clamps to the tight shape-derived footprint below, i.e. no extra
2654 // headroom -- identical to this function's own prior behaviour.
2655 unsafe { Self::from_foreign_with_capacity(ptr, shape, 0, owner, name) }
2656 }
2657
2658 /// [`Self::from_foreign`], additionally recording that the allocation
2659 /// behind `ptr` holds at least `capacity_bytes` bytes -- headroom beyond
2660 /// the tight `shape.product() * size_of::<T>()` footprint that a later
2661 /// [`configure_image`](Self::configure_image) / `set_logical_shape` can
2662 /// grow into without erroring.
2663 ///
2664 /// This is the consumer half of the cross-package capsule protocol's
2665 /// [`crate::protocol::TensorDesc::capacity`]: a producer's pool tensor
2666 /// (allocated for its largest expected image, holding a smaller one
2667 /// today) reports its real allocation size so the imported alias
2668 /// inherits the same headroom instead of being clamped to today's
2669 /// logical shape. `capacity_bytes` below the tight footprint is clamped
2670 /// up to it, never down -- the shape-implied floor always holds.
2671 ///
2672 /// # Safety
2673 ///
2674 /// Same as [`Self::from_foreign`], except `ptr` must be valid for
2675 /// `capacity_bytes` bytes (or the tight shape footprint, whichever is
2676 /// larger), not just the shape-implied footprint.
2677 ///
2678 /// # Errors
2679 ///
2680 /// [`Error::InvalidSize`] if `shape` is empty.
2681 pub unsafe fn from_foreign_with_capacity(
2682 ptr: *mut T,
2683 shape: &[usize],
2684 capacity_bytes: usize,
2685 owner: Option<crate::ForeignOwner>,
2686 name: Option<&str>,
2687 ) -> Result<Self> {
2688 if shape.is_empty() {
2689 return Err(Error::InvalidSize(0));
2690 }
2691 if ptr.is_null() {
2692 return Err(Error::InvalidArgument(
2693 "from_foreign: ptr must be non-null".to_owned(),
2694 ));
2695 }
2696 shape
2697 .iter()
2698 .copied()
2699 .try_fold(1usize, |acc, dim| acc.checked_mul(dim))
2700 .ok_or_else(|| {
2701 Error::InvalidArgument(format!(
2702 "from_foreign: shape.product() overflows usize (shape={shape:?})"
2703 ))
2704 })?;
2705 let capacity_elems = capacity_bytes / std::mem::size_of::<T>();
2706 // SAFETY: this function's own contract (see its doc comment) is
2707 // exactly `MemTensor::from_foreign_with_capacity`'s contract.
2708 let mem = unsafe {
2709 MemTensor::<T>::from_foreign_with_capacity(ptr, shape, capacity_elems, owner, name)
2710 };
2711 Ok(Self::wrap(TensorStorage::Mem(mem)))
2712 }
2713
2714 /// Pin a stable host address for this tensor's data.
2715 ///
2716 /// The returned [`HostPin`] carries **no borrow of the tensor**, so a
2717 /// caller can pin once at init and still pass `&mut self` to
2718 /// `ImageProcessor::convert` every frame. That borrow conflict is the
2719 /// blocker reported in [#134](https://github.com/EdgeFirstAI/hal/issues/134).
2720 ///
2721 /// The address stays valid until the pin is dropped, and survives
2722 /// re-layout: `configure_image` and `set_logical_shape` only rewrite the
2723 /// logical shape after a capacity check, and every backend's pin owns its
2724 /// own mapping, so nothing can move underneath it.
2725 ///
2726 /// A pin is therefore a **snapshot**, not a tracker: [`HostPin::len`]
2727 /// reports the extent at pin time and does not follow a later re-layout.
2728 /// That is the safe direction — the window always stays inside the
2729 /// allocation — but it means a caller who re-layouts should re-pin if it
2730 /// wants the new extent.
2731 ///
2732 /// The address is **not** a coherency guarantee: on non-coherent backends
2733 /// bracket CPU access with
2734 /// [`sync_for_cpu`](Self::sync_for_cpu) and
2735 /// [`sync_for_device`](Self::sync_for_device), which are no-ops where the
2736 /// backend does not require them.
2737 ///
2738 /// # Cost
2739 ///
2740 /// On `Mem` this is an `Arc` refcount bump — the backing allocation is
2741 /// never resized, so its base pointer is already stable. Other backends
2742 /// establish a mapping once and reuse it.
2743 ///
2744 /// # Errors
2745 ///
2746 /// - [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`]: a
2747 /// pinned mapping is CPU access by definition, so this is an error
2748 /// rather than a warn-and-stage.
2749 /// - [`Error::NotImplemented`] for the two backends that **cannot** pin,
2750 /// rather than merely not having been wired yet: a PBO
2751 /// (`glMapBufferRange` establishes the address and the visibility
2752 /// together, and there is no `GL_MAP_PERSISTENT_BIT` in GLES) and an
2753 /// AHardwareBuffer (`AHardwareBuffer_lock` is what yields the address at
2754 /// all, so there is no address outside the lock). Both name
2755 /// `map()`/`map_with()` as the alternative. `Mem`, `Shm`, DMA-BUF and
2756 /// IOSurface all pin.
2757 pub fn pin_host<'a>(&self, access: CpuAccess) -> Result<HostPin<'a>>
2758 where
2759 T: 'a,
2760 {
2761 if access == CpuAccess::None {
2762 return Err(Error::InvalidArgument(
2763 "pin_host: CpuAccess::None is not a valid pin; a pinned mapping \
2764 is CPU access by definition"
2765 .to_owned(),
2766 ));
2767 }
2768 // Backends yield everything addressable from the tensor's offset;
2769 // narrow to the logical extent so a consumer cannot mistake pitch
2770 // padding for data. A map guard keeps the wider window.
2771 let logical = self.len() * std::mem::size_of::<T>();
2772 let pin = match &self.storage {
2773 TensorStorage::Mem(m) => Ok(m.host_pin()),
2774 #[cfg(unix)]
2775 TensorStorage::Shm(s) => s.host_pin(),
2776 #[cfg(target_os = "linux")]
2777 TensorStorage::Dma(d) => d.host_pin(),
2778 #[cfg(any(target_os = "macos", target_os = "ios"))]
2779 TensorStorage::Dma(s) => s.host_pin(),
2780 // AHardwareBuffer cannot support a pin at all, and that is a
2781 // platform contract rather than missing work: the NDK exposes no
2782 // way to obtain a CPU address outside AHardwareBuffer_lock, so a
2783 // "persistent" address would mean holding the lock for the pin's
2784 // lifetime -- which blocks the GPU from the buffer and makes any
2785 // further lock undefined. Both are the opposite of what a pin is
2786 // for. Say so, rather than implying a TODO.
2787 #[cfg(target_os = "android")]
2788 TensorStorage::Dma(_) => Err(Error::NotImplemented(
2789 "pin_host: AHardwareBuffer has no host address outside \
2790 AHardwareBuffer_lock, so it cannot hand out one that outlives \
2791 a guard. Use map()/map_with() for CPU access; for zero-copy \
2792 handoff pass the buffer itself."
2793 .to_owned(),
2794 )),
2795 // PBO is the same shape of constraint as AHardwareBuffer:
2796 // glMapBufferRange's pointer is valid only until glUnmapBuffer, and
2797 // this backend maps with MAP_READ_BIT | MAP_WRITE_BIT -- no
2798 // GL_MAP_PERSISTENT_BIT, and no glBufferStorage anywhere. Holding
2799 // the map open for the pin's lifetime would keep the buffer mapped
2800 // across GL work, which is exactly what a PBO is not for.
2801 //
2802 // Persistent mapping (EXT_buffer_storage on GLES) would make this
2803 // implementable, but that is a buffer-allocation feature, not
2804 // wiring -- it changes how every PBO is created and needs fences
2805 // for coherency.
2806 TensorStorage::Pbo(_) => Err(Error::NotImplemented(
2807 "pin_host: a PBO has no host address outside \
2808 glMapBufferRange/glUnmapBuffer, so it cannot hand out one that \
2809 outlives a guard. Use map()/map_with(). Persistent mapping \
2810 (EXT_buffer_storage, GL_MAP_PERSISTENT_BIT) is not used by this \
2811 backend."
2812 .to_owned(),
2813 )),
2814 }?;
2815 Ok(pin.narrowed(logical))
2816 }
2817
2818 /// Make CPU reads/writes of this tensor coherent — the CPU is about to
2819 /// access the buffer.
2820 ///
2821 /// Corresponds to `DMA_BUF_SYNC_START`, and the name matches the Linux
2822 /// kernel DMA API for the same reason: `for_cpu` says who is about to
2823 /// touch the memory, which is what the caller actually knows.
2824 ///
2825 /// Pair with [`sync_for_device`](Self::sync_for_device).
2826 ///
2827 /// # Which backends owe maintenance
2828 ///
2829 /// | backend | behaviour |
2830 /// |---|---|
2831 /// | `Mem`, `Shm` | **no-op** — plain CPU memory, coherent by construction |
2832 /// | `Dma` (Linux DMA-BUF) | one `DMA_BUF_IOCTL_SYNC`, direction-tagged |
2833 /// | `Dma` (macOS/iOS IOSurface) | `IOSurfaceLock` / `IOSurfaceUnlock`, options matched to `access` |
2834 /// | `Dma` (Android AHardwareBuffer) | [`Error::NotImplemented`] — no sync independent of the lock |
2835 /// | `Pbo` | [`Error::NotImplemented`] — no coherency window independent of the map |
2836 ///
2837 /// Only the first row is a no-op, so "bracket unconditionally and pay
2838 /// nothing" holds for `Mem`/`Shm` alone. The two that report
2839 /// `NotImplemented` do so because their platform fuses addressing and
2840 /// coherency into one call — `AHardwareBuffer_lock` and
2841 /// `glMapBufferRange` each perform the maintenance *as* they hand out the
2842 /// address, so a separate bracket would have to nest inside a map guard
2843 /// that already owns the pair. Both name `map()`/`map_with()` as the
2844 /// alternative. They previously fell through a wildcard and answered
2845 /// `Ok(())`, which is worse than an error because the symptom is
2846 /// stale-but-plausible pixels rather than a failure. The match is
2847 /// exhaustive so a new backend cannot inherit that silence — it must state
2848 /// its coherency story to compile.
2849 ///
2850 /// # Cost
2851 ///
2852 /// `Mem`/`Shm`: nothing. `Dma` on Linux: one ioctl. IOSurface: one
2853 /// lock/unlock pair.
2854 ///
2855 /// # Errors
2856 ///
2857 /// [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`], which
2858 /// describes no access to make coherent.
2859 pub fn sync_for_cpu(&self, access: CpuAccess) -> Result<()> {
2860 Self::check_sync_access(access)?;
2861 match &self.storage {
2862 // Plain CPU memory: coherent by construction.
2863 TensorStorage::Mem(_) => Ok(()),
2864 #[cfg(unix)]
2865 TensorStorage::Shm(_) => Ok(()),
2866 #[cfg(target_os = "linux")]
2867 TensorStorage::Dma(d) => {
2868 // The direction tells the kernel which maintenance this access
2869 // needs: a read-only bracket skips the writeback at END, a
2870 // write-only one skips the invalidate at START. It MUST match
2871 // the direction passed to sync_for_device.
2872 crate::dmabuf::sync_access(&d.fd, true, access).map_err(Error::NixError)
2873 }
2874 #[cfg(any(target_os = "macos", target_os = "ios"))]
2875 TensorStorage::Dma(s) => s.sync_for_cpu(access),
2876 #[cfg(target_os = "android")]
2877 TensorStorage::Dma(_) => Err(Error::NotImplemented(
2878 "sync_for_cpu: AHardwareBuffer has no sync independent of its \
2879 lock. AHardwareBuffer_lock both establishes the address and \
2880 performs the cache invalidate, and the NDK leaves locking an \
2881 already-locked buffer undefined, so a bracket on top of a map \
2882 guard would nest. Use map()/map_with(), which owns the pair."
2883 .to_owned(),
2884 )),
2885 TensorStorage::Pbo(_) => Err(Error::NotImplemented(
2886 "sync_for_cpu: a PBO has no coherency window independent of its \
2887 map -- glMapBufferRange establishes the address and the \
2888 visibility together, and glUnmapBuffer publishes the writes. \
2889 Use map()/map_with(), which owns the pair."
2890 .to_owned(),
2891 )),
2892 }
2893 }
2894
2895 /// Release the buffer back to the device — the CPU is done accessing it.
2896 ///
2897 /// Corresponds to `DMA_BUF_SYNC_END`. See
2898 /// [`sync_for_cpu`](Self::sync_for_cpu) for the pairing and cost.
2899 pub fn sync_for_device(&self, access: CpuAccess) -> Result<()> {
2900 Self::check_sync_access(access)?;
2901 match &self.storage {
2902 TensorStorage::Mem(_) => Ok(()),
2903 #[cfg(unix)]
2904 TensorStorage::Shm(_) => Ok(()),
2905 #[cfg(target_os = "linux")]
2906 TensorStorage::Dma(d) => {
2907 crate::dmabuf::sync_access(&d.fd, false, access).map_err(Error::NixError)
2908 }
2909 #[cfg(any(target_os = "macos", target_os = "ios"))]
2910 TensorStorage::Dma(s) => s.sync_for_device(access),
2911 #[cfg(target_os = "android")]
2912 TensorStorage::Dma(_) => Err(Error::NotImplemented(
2913 "sync_for_device: AHardwareBuffer has no sync independent of \
2914 its lock — see sync_for_cpu. Use map()/map_with()."
2915 .to_owned(),
2916 )),
2917 TensorStorage::Pbo(_) => Err(Error::NotImplemented(
2918 "sync_for_device: a PBO has no coherency window independent of \
2919 its map -- see sync_for_cpu. Use map()/map_with()."
2920 .to_owned(),
2921 )),
2922 }
2923 }
2924
2925 fn check_sync_access(access: CpuAccess) -> Result<()> {
2926 if access == CpuAccess::None {
2927 return Err(Error::InvalidArgument(
2928 "sync_for_cpu/sync_for_device: CpuAccess::None describes no \
2929 access to synchronise"
2930 .to_owned(),
2931 ));
2932 }
2933 Ok(())
2934 }
2935
2936 /// Construct a tensor from a 3-D ndarray view. Respects strides — one
2937 /// copy in all cases; contiguous views take a memcpy fast path.
2938 ///
2939 /// Only available when the `ndarray` feature is enabled.
2940 #[cfg(feature = "ndarray")]
2941 pub fn from_arrayview3(view: ndarray::ArrayView3<'_, T>) -> Result<Self>
2942 where
2943 T: Copy,
2944 {
2945 let (h, w, c) = view.dim();
2946 let t = Self::new(&[h, w, c], Some(TensorMemory::Mem), None)?;
2947 {
2948 let mut m = t.map()?;
2949 let dst = m.as_mut_slice();
2950 if let Some(src) = view.as_slice() {
2951 dst.copy_from_slice(src);
2952 } else {
2953 for (d, &s) in dst.iter_mut().zip(view.iter()) {
2954 *d = s;
2955 }
2956 }
2957 }
2958 Ok(t)
2959 }
2960
2961 /// Create a new tensor with the given shape, memory type, and optional
2962 /// name. If no name is given, a random name will be generated. If no
2963 /// memory type is given, the best available memory type will be chosen
2964 /// based on the platform and environment variables.
2965 ///
2966 /// On Linux platforms, the order of preference is: Dma -> Shm -> Mem.
2967 /// On other Unix platforms (macOS), the order is: Shm -> Mem.
2968 /// On non-Unix platforms, only Mem is available.
2969 ///
2970 /// # Environment Variables
2971 /// - `EDGEFIRST_TENSOR_FORCE_MEM`: If set to a non-zero and non-false
2972 /// value, forces the use of regular system memory allocation
2973 /// (`TensorMemory::Mem`) regardless of platform capabilities.
2974 ///
2975 /// # Example
2976 /// ```rust
2977 /// use edgefirst_tensor::{Error, Tensor, TensorMemory, TensorTrait};
2978 /// # fn main() -> Result<(), Error> {
2979 /// let tensor = Tensor::<f32>::new(&[2, 3, 4], Some(TensorMemory::Mem), Some("test_tensor"))?;
2980 /// assert_eq!(tensor.memory(), TensorMemory::Mem);
2981 /// assert_eq!(tensor.name(), "test_tensor");
2982 /// # Ok(())
2983 /// # }
2984 /// ```
2985 pub fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
2986 let _span = tracing::trace_span!(
2987 "tensor.alloc",
2988 ?shape,
2989 memory = ?memory,
2990 dtype = std::any::type_name::<T>(),
2991 )
2992 .entered();
2993 #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
2994 let mut t = TensorStorage::new(shape, memory, name).map(Self::wrap)?;
2995 // Best-effort: attach a CUDA ExternalMemory handle for DMA tensors on
2996 // CUDA-capable hosts. Never blocks tensor creation on failure.
2997 // RUNTIME-UNVALIDATED: no CUDA+dma_heap test platform available; ABI
2998 // layout-asserted vs. CUDA 12.6 driver_types.h; mechanism proven by
2999 // gpu-probe O5 on Orin.
3000 #[cfg(target_os = "linux")]
3001 t.try_init_dma_cuda();
3002 Ok(t)
3003 }
3004
3005 /// Create an image tensor with the given format.
3006 /// Allocate an image tensor from a declarative request — the
3007 /// full-featured constructor behind [`Self::image`] and friends.
3008 ///
3009 /// Adds the [`Compression`] request to the classic parameters:
3010 ///
3011 /// - a request with any CPU access other than [`CpuAccess::None`] is
3012 /// [`Error::InvalidArgument`] (CPU mapping pins the layout linear);
3013 /// - [`Compression::Scheme`] fails with [`Error::NotImplemented`] on
3014 /// platforms without vendor tile compression, and with
3015 /// [`Error::InvalidArgument`] when the device's native scheme or
3016 /// the format eligibility doesn't match;
3017 /// - [`Compression::Any`] never fails for compression reasons: it
3018 /// records the scheme when the allocation is eligible and
3019 /// otherwise resolves linear, incrementing
3020 /// [`compression_fallback_count`].
3021 ///
3022 /// The recorded outcome is readable via [`Tensor::compression`].
3023 pub fn image_desc(desc: &ImageDesc) -> Result<Self>
3024 where
3025 T: 'static,
3026 {
3027 let Some(t_dtype) = dtype_of::<T>() else {
3028 return Err(Error::InvalidArgument(
3029 "image_desc: element type has no DType mapping".into(),
3030 ));
3031 };
3032 if t_dtype != desc.dtype {
3033 return Err(Error::InvalidArgument(format!(
3034 "image_desc: desc.dtype is {:?} but the tensor element type is {t_dtype:?}",
3035 desc.dtype
3036 )));
3037 }
3038
3039 // Compression-request guards. CPU access pins the layout linear,
3040 // so a request combined with any declared access is a
3041 // contradiction the caller should hear about immediately.
3042 if desc.compression.is_some() && desc.access != CpuAccess::None {
3043 return Err(Error::InvalidArgument(format!(
3044 "image_desc: a compression request requires CpuAccess::None (declared {:?}) — CPU mapping pins the layout linear",
3045 desc.access
3046 )));
3047 }
3048 if let Some(Compression::Scheme(requested)) = desc.compression {
3049 #[cfg(not(target_os = "android"))]
3050 {
3051 return Err(Error::NotImplemented(format!(
3052 "image_desc: Compression::Scheme({requested:?}) — no vendor tile compression on this platform (request Compression::Any for a portable fallback)"
3053 )));
3054 }
3055 #[cfg(target_os = "android")]
3056 {
3057 if matches!(
3058 desc.memory,
3059 Some(TensorMemory::Mem) | Some(TensorMemory::Shm) | Some(TensorMemory::Pbo)
3060 ) {
3061 return Err(Error::InvalidArgument(format!(
3062 "image_desc: Compression::Scheme({requested:?}) requires hardware memory (TensorMemory::DmaBuf or auto-select), got {:?}",
3063 desc.memory
3064 )));
3065 }
3066 if !crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype) {
3067 return Err(Error::InvalidArgument(format!(
3068 "image_desc: ({:?}, {:?}) is not compression-eligible (RGBA8888 u8/i8 initially)",
3069 desc.format, desc.dtype
3070 )));
3071 }
3072 let device = crate::ahardwarebuffer::device_compression_scheme();
3073 if device != Some(requested) {
3074 return Err(Error::InvalidArgument(format!(
3075 "image_desc: Compression::Scheme({requested:?}) but the device's native scheme is {device:?}"
3076 )));
3077 }
3078 }
3079 }
3080
3081 // A compression request implies a hardware pipeline, so auto
3082 // memory promotes to the platform's zero-copy allocation first
3083 // (`Tensor::image` only takes the AHardwareBuffer/IOSurface path
3084 // under an explicit Dma request). `Scheme` propagates the Dma
3085 // failure — the caller demanded a layout only that allocator can
3086 // produce; `Any` falls back to plain auto-select.
3087 #[allow(unused_mut)]
3088 let mut t = match (desc.memory, desc.compression) {
3089 (None, Some(request)) => {
3090 match Self::image(
3091 desc.width,
3092 desc.height,
3093 desc.format,
3094 Some(TensorMemory::DmaBuf),
3095 desc.access,
3096 ) {
3097 Ok(t) => t,
3098 Err(e) if matches!(request, Compression::Scheme(_)) => return Err(e),
3099 Err(_) => Self::image(desc.width, desc.height, desc.format, None, desc.access)?,
3100 }
3101 }
3102 (memory, _) => Self::image(desc.width, desc.height, desc.format, memory, desc.access)?,
3103 };
3104
3105 // Record best knowledge / count fallbacks. Only an Android
3106 // hardware-only AHardwareBuffer allocation can actually hold a
3107 // vendor tile layout; everywhere else an Any request resolves
3108 // linear and is counted.
3109 if let Some(request) = desc.compression {
3110 #[cfg(target_os = "android")]
3111 {
3112 let eligible =
3113 crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype);
3114 let scheme = crate::ahardwarebuffer::device_compression_scheme();
3115 let is_ahb = t.memory() == TensorMemory::DmaBuf;
3116 match (request, scheme) {
3117 (_, Some(s)) if eligible && is_ahb => {
3118 t.set_compression_unchecked(Some(s));
3119 }
3120 (Compression::Scheme(requested), _) => {
3121 // Pre-validated eligible + scheme match, so the only
3122 // way here is the allocation resolving off-AHB.
3123 return Err(Error::InvalidOperation(format!(
3124 "image_desc: Compression::Scheme({requested:?}) requested but the allocation resolved to {:?} (not an AHardwareBuffer)",
3125 t.memory()
3126 )));
3127 }
3128 (Compression::Any, _) => {
3129 note_compression_fallback(&format!(
3130 "({:?}, {:?}) {}x{}: eligible={eligible}, scheme={scheme:?}, memory={:?}",
3131 desc.format,
3132 desc.dtype,
3133 desc.width,
3134 desc.height,
3135 t.memory()
3136 ));
3137 }
3138 }
3139 }
3140 #[cfg(not(target_os = "android"))]
3141 {
3142 debug_assert!(matches!(request, Compression::Any));
3143 let _ = request;
3144 note_compression_fallback(&format!(
3145 "({:?}, {:?}) {}x{}: no vendor tile compression on this platform",
3146 desc.format, desc.dtype, desc.width, desc.height
3147 ));
3148 }
3149 }
3150 Ok(t)
3151 }
3152
3153 /// Allocate an image tensor of `width` × `height` in `format`.
3154 ///
3155 /// This is the common-case image constructor. The shape follows from the
3156 /// format's layout — packed `[H, W, C]`, planar `[C, H, W]`, semi-planar
3157 /// `[H + chroma_rows, W]` — and the format is recorded on the tensor, so
3158 /// the image crate and the codec can work from it without out-of-band
3159 /// parameters.
3160 ///
3161 /// `memory` requests a backend, or `None` to auto-select. Auto-select for
3162 /// images is **DMA → Mem**: unlike [`Tensor::new`] it does not try SHM,
3163 /// which offers an image nothing that Mem doesn't (it is not
3164 /// GPU-importable, and Mem always succeeds). Ask for
3165 /// [`TensorMemory::Shm`] explicitly if you need it.
3166 ///
3167 /// `access` declares what the **CPU** intends to do with the buffer and is
3168 /// required; see [`CpuAccess`]. Hardware access — GPU, NPU, ISP, codec —
3169 /// is always implied and never declared. Prefer the narrowest declaration
3170 /// that fits: `None` keeps the allocation eligible for Android vendor tile
3171 /// compression, and anything else pins the layout linear.
3172 ///
3173 /// DMA-backed images always get a **64-byte-aligned row stride**, in every
3174 /// layout, because Mali and Vivante reject an EGLImage import at an
3175 /// unaligned pitch. Read the real pitch back with
3176 /// [`Tensor::effective_row_stride`] rather than assuming `width × bpp`.
3177 ///
3178 /// For a compression request or any other less common knob, build an
3179 /// [`ImageDesc`] and call [`Tensor::image_desc`].
3180 ///
3181 /// # Errors
3182 ///
3183 /// - [`Error::InvalidArgument`] if `width` × `height` is not a valid size
3184 /// for `format` (for example odd dimensions where the format's chroma
3185 /// subsampling forbids them), or, on macOS with an explicit
3186 /// [`TensorMemory::DmaBuf`], if the format cannot be expressed as an
3187 /// image-formatted IOSurface at this width. The message names the
3188 /// aligned width to use.
3189 /// - Whatever the chosen backend returns if the allocation itself fails —
3190 /// for an explicit `memory` request there is no fallback.
3191 ///
3192 /// # Example
3193 ///
3194 /// ```rust
3195 /// use edgefirst_tensor::{CpuAccess, PixelFormat, Tensor, TensorMemory};
3196 ///
3197 /// # fn main() -> Result<(), edgefirst_tensor::Error> {
3198 /// // A camera frame the GPU converts and the CPU never touches.
3199 /// let frame = Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12,
3200 /// Some(TensorMemory::Mem), CpuAccess::None)?;
3201 /// assert_eq!(frame.format(), Some(PixelFormat::Nv12));
3202 /// # Ok(())
3203 /// # }
3204 /// ```
3205 pub fn image(
3206 width: usize,
3207 height: usize,
3208 format: PixelFormat,
3209 memory: Option<TensorMemory>,
3210 access: CpuAccess,
3211 ) -> Result<Self>
3212 where
3213 T: 'static,
3214 {
3215 // Shape comes from the shared `PixelFormat::allocation_shape` helper (packed /
3216 // planar / semi-planar NV12·NV16). NV12 supports odd dimensions via the
3217 // `H + ceil(H/2)` combined-plane height.
3218 // The `T: 'static` bound is required by the macOS IOSurface path below.
3219 let shape = format.allocation_shape(width, height).ok_or_else(|| {
3220 Error::InvalidArgument(format!(
3221 "invalid dimensions {width}x{height} for format {format:?}"
3222 ))
3223 })?;
3224
3225 // macOS Dma path: allocate a format-aware IOSurface (FourCC +
3226 // 2D dimensions) so the GL backend can bind it via
3227 // `EGL_ANGLE_iosurface_client_buffer`. Without this, the IOSurface
3228 // would default to a generic byte buffer (FourCC 'L008') and
3229 // ANGLE would reject the import with `EGL_BAD_ATTRIBUTE`.
3230 //
3231 // Guard: IOSurface rounds `bytes_per_row` up to 64-byte alignment.
3232 // If the natural row pitch (`width * channels * sizeof(T)`) is not
3233 // already 64-byte aligned, the padded allocation cannot be mapped
3234 // as a contiguous packed tensor — CPU reads/writes would use the
3235 // wrong stride.
3236 //
3237 // Explicit-Dma contract: when the caller passes
3238 // `Some(TensorMemory::DmaBuf)` they have asked for an
3239 // **image-formatted IOSurface**. Silently downgrading to the
3240 // generic 'L008' byte-bag when alignment fails buries the
3241 // mismatch — the caller only finds out hours later when ANGLE
3242 // (or any GL importer) rejects the bind with
3243 // `EGL_BAD_ATTRIBUTE`. Same anti-pattern bit us previously on
3244 // Mali GPUs with DMA-BUF padding. The right behaviour is to
3245 // fail loudly here with the alignment requirement spelled out
3246 // so the caller can either pick aligned dimensions, request
3247 // SHM/Mem explicitly, or pass `memory=None` for auto-select.
3248 #[cfg(any(target_os = "macos", target_os = "ios"))]
3249 if matches!(memory, Some(TensorMemory::DmaBuf)) {
3250 // For planar formats the IOSurface stacks channels
3251 // vertically (channels * height rows), so the row stride is
3252 // single-channel width * sizeof(T). Packed formats keep the
3253 // natural width * channels * sizeof(T) stride.
3254 let natural_row_bytes = match format.layout() {
3255 PixelLayout::Planar => width * std::mem::size_of::<T>(),
3256 _ => width * format.channels() * std::mem::size_of::<T>(),
3257 };
3258 // A format with a real IOSurface FourCC (RGBA/BGRA/YUYV packed,
3259 // GREY/NV12/NV16/NV24 as R8) tolerates a non-64-aligned natural
3260 // pitch: the surface is allocated with its own 64-aligned
3261 // `bytes_per_row`, the tensor records that stride below, and a CPU
3262 // map iterates rows correctly via the strided-map path while the GL
3263 // import uses the surface's pitch directly — fully zero-copy.
3264 // Planar (the F16 RGBA16F packing) is consumed flat as
3265 // `[1, C, H, W]` with no stride, so it still requires an aligned
3266 // pitch; and formats without a FourCC would fall through to a
3267 // generic byte-bag GL can't bind. Both fail loudly rather than
3268 // silently downgrade.
3269 let has_image_fourcc = dtype_of::<T>()
3270 .and_then(|dt| crate::iosurface_layout::image_iosurface_layout(format, dt))
3271 .is_some();
3272 let padded_ok = has_image_fourcc && format.layout() != PixelLayout::Planar;
3273 if !natural_row_bytes.is_multiple_of(64) && !padded_ok {
3274 let elem_size = std::mem::size_of::<T>();
3275 let per_pixel_bytes = match format.layout() {
3276 PixelLayout::Planar => elem_size.max(1),
3277 _ => format.channels().max(1) * elem_size.max(1),
3278 };
3279 // Compute the next 64-byte-aligned width by rounding the
3280 // natural row pitch up to the next multiple of 64 and
3281 // dividing back by per-pixel bytes. This handles every
3282 // `per_pixel_bytes` value correctly:
3283 //
3284 // * Divisors of 64 (1/2/4/8/16/32/64) → the suggestion
3285 // is always 64-byte aligned.
3286 // * Non-divisors of 64 (e.g. RGB u8 with 3 B/pixel) →
3287 // the next aligned row pitch may not be an integer
3288 // multiple of per_pixel_bytes (3 doesn't divide 64
3289 // in any way), so a "pad width to N" suggestion is
3290 // structurally impossible — omit the suggestion
3291 // instead of printing a wrong number.
3292 // * per_pixel_bytes > 64 → same situation, also
3293 // omitted; the previous formula divided by zero.
3294 //
3295 // The error always names the alignment requirement
3296 // verbatim and lists the two non-DMA alternatives so
3297 // the caller has at least one always-applicable fix.
3298 let aligned_row_bytes = natural_row_bytes.next_multiple_of(64);
3299 let pad_hint =
3300 if per_pixel_bytes > 0 && aligned_row_bytes.is_multiple_of(per_pixel_bytes) {
3301 let w = aligned_row_bytes / per_pixel_bytes;
3302 format!("Pad width to {w} (the next 64-byte-aligned stride), ")
3303 } else {
3304 String::new()
3305 };
3306 return Err(Error::InvalidArgument(format!(
3307 "Tensor::image: {format:?} {width}x{height} with element \
3308 size {elem_size} produces a {natural_row_bytes}-byte natural \
3309 row pitch, which is not 64-byte aligned. \
3310 IOSurface rounds bytes_per_row up to 64 bytes, so a \
3311 contiguous CPU map of this tensor would read garbage. \
3312 {pad_hint}pass memory=None to auto-fall-back to SHM, or \
3313 pass memory=Some(TensorMemory::Shm) or \
3314 Some(TensorMemory::Mem) explicitly."
3315 )));
3316 }
3317 // Alignment OK. Explicit-Dma contract: the caller asked for an
3318 // **image-formatted, GL-importable** IOSurface, so every failure
3319 // from here on is loud. The old behaviour fell through to the
3320 // generic 'L008' byte-bag `Some(other)` arm below, and the caller
3321 // only found out when the GL import rejected the bind with
3322 // `EGL_BAD_ATTRIBUTE` — the same silent-downgrade anti-pattern as
3323 // the alignment case above. (Semi-planar/Grey u8 map to 'L008'
3324 // *by design* in `image_iosurface_layout` — the R8-plane
3325 // representation the YUV shaders sample — so they return through
3326 // the mapped path and never reach these errors.)
3327 let dtype = dtype_of::<T>().ok_or_else(|| {
3328 Error::InvalidArgument(format!(
3329 "Tensor::image: element type {} has no DType, so no \
3330 image-formatted IOSurface exists. Pass memory=None or \
3331 Some(TensorMemory::Mem) for a CPU tensor.",
3332 std::any::type_name::<T>()
3333 ))
3334 })?;
3335 if crate::iosurface_layout::image_iosurface_layout(format, dtype).is_none() {
3336 return Err(Error::InvalidArgument(format!(
3337 "Tensor::image: no zero-copy IOSurface mapping exists for \
3338 {format:?}/{dtype:?} on macOS/iOS (supported: \
3339 Rgba/Rgb @ U8/I8, Bgra/Yuyv/Grey/Nv12/Nv16/Nv24 @ U8, \
3340 Rgba/PlanarRgb/PlanarRgba @ F16). Pass memory=None to \
3341 auto-select, or Some(TensorMemory::Mem) explicitly, for \
3342 a CPU tensor."
3343 )));
3344 }
3345 // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3346 // reject a width the texel packing cannot express up front
3347 // (mirrors the Android pre-guard).
3348 if format == PixelFormat::Rgb
3349 && matches!(dtype, DType::U8 | DType::I8)
3350 && packed_rgb888_layout(width, height).is_none()
3351 {
3352 return Err(Error::InvalidArgument(format!(
3353 "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3354 IOSurface packing (got width={width}). Pad the width, or pass \
3355 memory=Some(TensorMemory::Mem) for a CPU tensor."
3356 )));
3357 }
3358 let storage = TensorStorage::<T>::new_image_iosurface(
3359 width, height, format, dtype, &shape, None,
3360 )?;
3361 let mut t = Self::wrap(storage);
3362 t.format = Some(format);
3363 // IOSurface rounds `bytes_per_row` up to 64 bytes. When that
3364 // pitch exceeds the natural packed/planar row stride, record
3365 // it so CPU consumers iterate rows correctly (the GL import
3366 // already uses the surface's own pitch). For 64-aligned rows
3367 // — the common model-input case — the two match and no stride
3368 // is stored, leaving the flat mapping unchanged.
3369 if let TensorStorage::Dma(ref io) = t.storage {
3370 let bpr = io.bytes_per_row();
3371 if let Some(natural) = t.effective_row_stride() {
3372 if bpr > natural {
3373 t.set_row_stride_unchecked(bpr);
3374 }
3375 }
3376 }
3377 t.cpu_access = access;
3378 return Ok(t);
3379 }
3380
3381 // Android Dma path: allocate a format-aware AHardwareBuffer so the
3382 // GL backend can import it as an EGLImage
3383 // (`eglGetNativeClientBufferANDROID` → `eglCreateImageKHR`).
3384 //
3385 // Geometry: gralloc chooses the row pitch (`desc.stride`) at
3386 // allocation, and pads freely (validated on the Galaxy S26 Ultra,
3387 // where SnapAlloc pads the planar-F16 RGBA16F surface). A padded
3388 // pitch is recorded on the tensor so CPU maps iterate rows via the
3389 // strided-map path; the GL render uses the buffer's own pitch
3390 // through the EGLImage either way, so the GPU path stays fully
3391 // zero-copy. Consumers needing a FLAT layout (the future NPU
3392 // handoff's `[1, C, H, W]` contract) must check `row_stride()` and
3393 // repack when set — flatness is a per-device property here, unlike
3394 // macOS where IOSurface's 64-BYTE alignment keeps model-sized F16
3395 // surfaces naturally flat.
3396 #[cfg(target_os = "android")]
3397 if matches!(memory, Some(TensorMemory::DmaBuf)) {
3398 // Explicit-Dma contract (mirrors the macOS block above): the
3399 // caller asked for an image-formatted, GL-importable
3400 // AHardwareBuffer, so an unmapped combination or a gralloc
3401 // refusal errors here instead of falling through to a BLOB
3402 // byte-bag the GL backend can never import — that downgrade
3403 // only surfaced as a silent per-frame CPU upload.
3404 let dtype = dtype_of::<T>().ok_or_else(|| {
3405 Error::InvalidArgument(format!(
3406 "Tensor::image: element type {} has no DType, so no \
3407 image-formatted AHardwareBuffer exists. Pass memory=None \
3408 or Some(TensorMemory::Mem) for a CPU tensor.",
3409 std::any::type_name::<T>()
3410 ))
3411 })?;
3412 // Planar F16 requires width % 4 == 0 for the RGBA16F
3413 // packing — reject up front with the requirement spelled
3414 // out (mirrors the macOS pre-allocation guard) instead of
3415 // falling through to a byte-bag GL cannot bind.
3416 if format.layout() == PixelLayout::Planar
3417 && dtype == DType::F16
3418 && packed_rgba16f_layout(format, dtype, width, height).is_none()
3419 {
3420 return Err(Error::InvalidArgument(format!(
3421 "Tensor::image: {format:?} F16 requires width%4==0 for the RGBA16F \
3422 AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3423 memory=Some(TensorMemory::Mem) for a CPU tensor."
3424 )));
3425 }
3426 // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3427 // the same whole-texel constraint as the F16 packing above.
3428 if format == PixelFormat::Rgb
3429 && matches!(dtype, DType::U8 | DType::I8)
3430 && packed_rgb888_layout(width, height).is_none()
3431 {
3432 return Err(Error::InvalidArgument(format!(
3433 "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3434 AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3435 memory=Some(TensorMemory::Mem) for a CPU tensor."
3436 )));
3437 }
3438 if crate::ahardwarebuffer::image_ahardwarebuffer_layout(format, dtype).is_none() {
3439 return Err(Error::InvalidArgument(format!(
3440 "Tensor::image: no zero-copy AHardwareBuffer mapping exists \
3441 for {format:?}/{dtype:?} on Android (Grey/NV* need \
3442 R8-format buffers, API 29+; the HAL floor is API 26 — see \
3443 `image_ahardwarebuffer_layout`). Pass memory=None to \
3444 auto-select, or Some(TensorMemory::Mem) explicitly, for a \
3445 CPU tensor; camera NV12 stays zero-copy by wrapping the \
3446 camera's own AHardwareBuffer instead of allocating one."
3447 )));
3448 }
3449 let storage = TensorStorage::<T>::new_image_ahardwarebuffer(
3450 width, height, format, dtype, &shape, None, access,
3451 )?;
3452 let mut t = Self::wrap(storage);
3453 t.format = Some(format);
3454 if let TensorStorage::Dma(ref ahb) = t.storage {
3455 // gralloc chooses the row pitch (Qualcomm's
3456 // SnapAlloc pads e.g. the 160-px-wide RGBA16F
3457 // surface of a 640-wide planar F16 target).
3458 // When it exceeds the natural stride, record
3459 // it so CPU consumers iterate rows correctly
3460 // via `effective_row_stride()` — the GPU
3461 // renders through the EGLImage at the
3462 // buffer's real pitch regardless, so the
3463 // render stays fully zero-copy. Consumers
3464 // that need the FLAT `[1, C, H, W]` layout
3465 // (the future NPU handoff) must check
3466 // `row_stride()` is unset and repack — or
3467 // pick an aligned width — rather than assume
3468 // flatness (see the module docs).
3469 let bpr = ahb.bytes_per_row();
3470 if let Some(natural) = t.effective_row_stride() {
3471 if bpr > natural {
3472 log::debug!(
3473 "Tensor::image: gralloc padded the {format:?} \
3474 AHardwareBuffer pitch to {bpr} bytes (natural \
3475 {natural}); recording row stride"
3476 );
3477 t.set_row_stride_unchecked(bpr);
3478 }
3479 }
3480 }
3481 t.cpu_access = access;
3482 return Ok(t);
3483 }
3484
3485 // Compute the **64-byte-aligned** row stride for every image layout.
3486 //
3487 // Embedded GPUs reject `eglCreateImage` DMA-BUF imports whose row pitch
3488 // is not 64-byte aligned: Mali returns `EGL_BAD_ALLOC`, Vivante
3489 // `EGL_BAD_ACCESS`. This bit packed RGBA/RGB destinations at odd widths
3490 // AND at even non-multiple-of-16 widths (e.g. 321→1284, 322→1288 bytes —
3491 // neither divisible by 64), so an odd-source → RGBA convert failed on
3492 // imx95/imx8mp while succeeding on V3D/Tegra. Semi-planar already aligned
3493 // here; we now align packed and planar identically so every image()
3494 // allocation is GPU-importable regardless of width.
3495 //
3496 // The per-layout natural pitch and total row count:
3497 // * SemiPlanar `[total_h, width]` — pitch = even(width)·elem, rows = total_h
3498 // * Packed `[height, width, ch]` — pitch = width·ch·elem, rows = height
3499 // * Planar `[ch, height, width]` — pitch = width·elem, rows = ch·height
3500 // Allocation byte size = `aligned_stride · total_rows` (NOT the shape
3501 // product, which reflects only the logical width and under-allocates the
3502 // padding on odd / unaligned widths).
3503 let elem = std::mem::size_of::<T>();
3504 let channels = format.channels();
3505 let (natural_stride, total_rows) = match format.layout() {
3506 PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
3507 PixelLayout::Packed => (width * channels * elem, height),
3508 PixelLayout::Planar => (width * elem, channels * height),
3509 };
3510 let aligned_stride = natural_stride.next_multiple_of(64);
3511 let semi = format.layout() == PixelLayout::SemiPlanar;
3512
3513 // DMA buffers MUST carry a 64-aligned row pitch — Mali/Vivante reject a
3514 // DMA-BUF EGLImage whose pitch is not 64-aligned. Semi-planar also needs
3515 // the aligned pitch on every backend (its chroma-plane offset math
3516 // assumes it). Packed/planar on host-only memory (Mem/Shm) keep the
3517 // natural tight pitch so the many flat CPU consumers are unaffected.
3518 let host_stride = if semi { aligned_stride } else { natural_stride };
3519 let host_byte_size = host_stride * total_rows;
3520 #[cfg(target_os = "linux")]
3521 let dma_byte_size = aligned_stride * total_rows;
3522
3523 // `used_stride` is the actual row pitch of the storage created below.
3524 let (storage, used_stride) = match memory {
3525 #[cfg(target_os = "linux")]
3526 Some(TensorMemory::DmaBuf) => (
3527 TensorStorage::<T>::new_dma_with_byte_size(&shape, dma_byte_size, None)?,
3528 aligned_stride,
3529 ),
3530 #[cfg(unix)]
3531 Some(TensorMemory::Shm) => (
3532 TensorStorage::<T>::new_shm_with_byte_size(&shape, host_byte_size, None)?,
3533 host_stride,
3534 ),
3535 Some(TensorMemory::Mem) => (
3536 TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
3537 host_stride,
3538 ),
3539 #[allow(unused_variables)]
3540 Some(other) => {
3541 // PBO and any future variants: fall through to standard new().
3542 return {
3543 let mut t = Self::new(&shape, Some(other), None)?;
3544 t.format = Some(format);
3545 t.cpu_access = access;
3546 Ok(t)
3547 };
3548 }
3549 None => {
3550 // Auto-select priority: DMA → Mem (DMA gets the 64-aligned
3551 // pitch; the Mem fallback keeps the tight host pitch, so the
3552 // recorded stride matches the storage used). Shm is NOT
3553 // auto-selected — it offers no advantage over Mem for an
3554 // in-process image and Mem always succeeds, so it sits below Mem
3555 // and is reached only via an explicit `TensorMemory::Shm`.
3556 #[cfg(target_os = "linux")]
3557 {
3558 match TensorStorage::<T>::new_dma_with_byte_size(&shape, dma_byte_size, None) {
3559 Ok(s) => (s, aligned_stride),
3560 Err(_) => (
3561 TensorStorage::<T>::new_mem_with_byte_size(
3562 &shape,
3563 host_byte_size,
3564 None,
3565 )?,
3566 host_stride,
3567 ),
3568 }
3569 }
3570 #[cfg(not(target_os = "linux"))]
3571 {
3572 (
3573 TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
3574 host_stride,
3575 )
3576 }
3577 }
3578 };
3579
3580 let mut t = Self::wrap(storage);
3581 t.format = Some(format);
3582 // Record the row stride when it exceeds the natural tight pitch (padding
3583 // is present — DMA packed/planar at an unaligned width, or always for
3584 // semi-planar), mirroring the IOSurface path above. Aligned-width and
3585 // host-only packed/planar images keep their flat layout with no explicit
3586 // stride; `effective_row_stride()` then falls back to the identical
3587 // computed pitch. When padding IS present, consumers must iterate rows by
3588 // `effective_row_stride()` to skip it.
3589 if semi || used_stride > natural_stride {
3590 t.set_row_stride_unchecked(used_stride);
3591 }
3592 debug_assert!(
3593 t.row_stride.is_some() || !semi,
3594 "image() must always set row_stride for semi-planar tensors"
3595 );
3596 t.cpu_access = access;
3597 #[cfg(target_os = "linux")]
3598 t.try_init_dma_cuda();
3599 Ok(t)
3600 }
3601
3602 /// Create a DMA-backed image tensor with an explicit row stride that
3603 /// may exceed the natural `width * channels * sizeof(T)` pitch.
3604 ///
3605 /// Used for image tensors that need GPU pitch alignment padding: the
3606 /// underlying DMA-BUF is sized to `row_stride * height` bytes, but
3607 /// the tensor's logical shape stays at `[height, width, channels]`.
3608 /// `width()` / `height()` / `shape()` continue to report the
3609 /// user-requested values; the padding is visible only via
3610 /// `row_stride()` / `effective_row_stride()` and is automatically
3611 /// propagated to the GL backend's EGLImage import so Mali Valhall
3612 /// accepts the buffer.
3613 ///
3614 /// # Supported formats
3615 ///
3616 /// Currently only **packed** pixel layouts (RGBA8, BGRA8, RGB888,
3617 /// Grey, etc.) are supported — the formats the GL backend uses as
3618 /// render destinations. Semi-planar formats (NV12, NV16) come from
3619 /// external allocators (camera capture, video decoders) and are
3620 /// imported via `TensorDyn::from_fd` + `set_row_stride`, which
3621 /// already supports padded strides.
3622 ///
3623 /// # Supported memory
3624 ///
3625 /// Currently only `TensorMemory::DmaBuf` is supported. PBO and Mem
3626 /// storage don't go through EGLImage import so they don't need
3627 /// pitch alignment; if you pass any other memory type this returns
3628 /// `NotImplemented`. `None` (auto-select) is treated as `Dma`.
3629 ///
3630 /// # Errors
3631 ///
3632 /// - `InvalidArgument` if `row_stride_bytes < width * channels * sizeof(T)`
3633 /// (the requested stride would not fit a single row)
3634 /// - `NotImplemented` for non-packed formats or non-DMA memory
3635 /// - `IoError` if the DMA-heap allocation fails (propagated from
3636 /// `DmaTensor::new_with_byte_size`)
3637 pub fn image_with_stride(
3638 width: usize,
3639 height: usize,
3640 format: PixelFormat,
3641 row_stride_bytes: usize,
3642 memory: Option<TensorMemory>,
3643 access: CpuAccess,
3644 ) -> Result<Self> {
3645 #[cfg(not(target_os = "linux"))]
3646 let _ = access;
3647 // DMA backing (the only thing this constructor produces) is
3648 // Linux-only. On macOS/BSD/Windows the non-Linux block below is
3649 // the only compiled body and returns `NotImplemented` directly;
3650 // on Linux the non-Linux block is cfg-removed and the function
3651 // falls through to the real validation + allocation path. Each
3652 // target compiles exactly one of the two blocks, and the block
3653 // serves as the function's tail expression in both cases — so
3654 // neither needs an explicit `return` (avoids
3655 // `clippy::needless_return` on the macOS CI gate).
3656 #[cfg(not(target_os = "linux"))]
3657 {
3658 let _ = (width, height, format, row_stride_bytes, memory);
3659 Err(Error::NotImplemented(
3660 "image_with_stride requires DMA support (Linux only)".to_owned(),
3661 ))
3662 }
3663
3664 #[cfg(target_os = "linux")]
3665 {
3666 if format.layout() != PixelLayout::Packed {
3667 return Err(Error::NotImplemented(format!(
3668 "Tensor::image_with_stride only supports packed pixel layouts, got {format:?}"
3669 )));
3670 }
3671 let elem = std::mem::size_of::<T>();
3672 let min_stride = width
3673 .checked_mul(format.channels())
3674 .and_then(|p| p.checked_mul(elem))
3675 .ok_or_else(|| {
3676 Error::InvalidArgument(format!(
3677 "image_with_stride: width {width} × channels {} × sizeof::<T>={elem} \
3678 overflows usize",
3679 format.channels()
3680 ))
3681 })?;
3682 if row_stride_bytes < min_stride {
3683 return Err(Error::InvalidArgument(format!(
3684 "image_with_stride: row_stride {row_stride_bytes} < minimum {min_stride} \
3685 ({width} px × {} ch × {elem} B)",
3686 format.channels()
3687 )));
3688 }
3689 let total_byte_size = row_stride_bytes.checked_mul(height).ok_or_else(|| {
3690 Error::InvalidArgument(format!(
3691 "image_with_stride: row_stride {row_stride_bytes} × height {height} overflows usize"
3692 ))
3693 })?;
3694
3695 let shape = vec![height, width, format.channels()];
3696
3697 let storage = match memory {
3698 Some(TensorMemory::DmaBuf) | None => {
3699 TensorStorage::<T>::new_dma_with_byte_size(&shape, total_byte_size, None)?
3700 }
3701 Some(other) => {
3702 return Err(Error::NotImplemented(format!(
3703 "image_with_stride: only TensorMemory::DmaBuf is supported, got {other:?}"
3704 )));
3705 }
3706 };
3707
3708 let mut t = Self::wrap(storage);
3709 t.format = Some(format);
3710 t.row_stride = Some(row_stride_bytes);
3711 t.cpu_access = access;
3712 // Match new()/from_fd(): a DMA tensor must attempt CUDA external-
3713 // memory import so a strided DMA buffer is also zero-copy
3714 // CUDA-mappable (no-op when libcudart is absent).
3715 t.try_init_dma_cuda();
3716 Ok(t)
3717 }
3718 }
3719
3720 /// Attach format metadata to an existing tensor.
3721 ///
3722 /// # Arguments
3723 ///
3724 /// * `format` - The pixel format to attach
3725 ///
3726 /// # Returns
3727 ///
3728 /// `Ok(())` on success, with the format stored as metadata on the tensor.
3729 ///
3730 /// # Errors
3731 ///
3732 /// Returns `Error::InvalidShape` if the tensor shape is incompatible with
3733 /// the format's layout (packed expects `[H, W, C]`, planar expects
3734 /// `[C, H, W]`, semi-planar expects `[H*k, W]` with format-specific
3735 /// height constraints).
3736 pub fn set_format(&mut self, format: PixelFormat) -> Result<()> {
3737 let shape = self.shape();
3738 match format.layout() {
3739 PixelLayout::Packed => {
3740 if shape.len() != 3 || shape[2] != format.channels() {
3741 return Err(Error::InvalidShape(format!(
3742 "packed format {format:?} expects [H, W, {}], got {shape:?}",
3743 format.channels()
3744 )));
3745 }
3746 }
3747 PixelLayout::Planar => {
3748 if shape.len() != 3 || shape[0] != format.channels() {
3749 return Err(Error::InvalidShape(format!(
3750 "planar format {format:?} expects [{}, H, W], got {shape:?}",
3751 format.channels()
3752 )));
3753 }
3754 }
3755 PixelLayout::SemiPlanar => {
3756 if shape.len() != 2 {
3757 return Err(Error::InvalidShape(format!(
3758 "semi-planar format {format:?} expects [H*k, W], got {shape:?}"
3759 )));
3760 }
3761 match format {
3762 // Combined-plane height is `H + ceil(H/2)` (luma + chroma
3763 // rows). For even H that is `3H/2` (≡ 0 mod 3); for odd H it
3764 // is `(3H+1)/2` (≡ 2 mod 3). Only totals ≡ 1 mod 3 are
3765 // unreachable, so reject just those — odd-height NV12 is
3766 // valid (e.g. 725 rows for a 483-tall image).
3767 PixelFormat::Nv12 if shape[0] % 3 == 1 => {
3768 return Err(Error::InvalidShape(format!(
3769 "NV12 contiguous shape[0] must be H + ceil(H/2) for some height; \
3770 {} is unreachable (≡ 1 mod 3)",
3771 shape[0]
3772 )));
3773 }
3774 PixelFormat::Nv16 if !shape[0].is_multiple_of(2) => {
3775 return Err(Error::InvalidShape(format!(
3776 "NV16 contiguous shape[0] must be even, got {}",
3777 shape[0]
3778 )));
3779 }
3780 // NV24 (4:4:4): combined-plane height is 3H (Y + 2H chroma).
3781 PixelFormat::Nv24 if !shape[0].is_multiple_of(3) => {
3782 return Err(Error::InvalidShape(format!(
3783 "NV24 contiguous shape[0] must be a multiple of 3 (= 3H), got {}",
3784 shape[0]
3785 )));
3786 }
3787 _ => {}
3788 }
3789 }
3790 }
3791 // Clear stored stride/offset when format changes — they may be invalid
3792 // for the new format. Caller must re-set after changing format.
3793 if self.format != Some(format) {
3794 self.row_stride = None;
3795 self.plane_offset = None;
3796 match self.storage {
3797 TensorStorage::Mem(ref mut m) => m.set_offset(0),
3798 #[cfg(target_os = "linux")]
3799 TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
3800 _ => {}
3801 }
3802 }
3803 self.format = Some(format);
3804 Ok(())
3805 }
3806
3807 /// Set this tensor's logical dimensions and pixel format to a decoded
3808 /// image, reusing the existing allocation. The shape is derived from the
3809 /// format layout; fails with `Error::InsufficientCapacity` if the
3810 /// allocation cannot hold `width`×`height` in `format`, or
3811 /// `Error::InvalidArgument` if the dimensions are invalid for the format.
3812 ///
3813 /// For NV12/NV16/NV24 the buffer width is rounded up to even (a chroma-plane
3814 /// interleaving requirement); the true odd width is reported by the decoder
3815 /// in `ImageInfo` and trimmed by a `convert()` crop. See
3816 /// [`PixelFormat::allocation_shape`].
3817 ///
3818 /// When the backing has a fixed physical row pitch (an IOSurface's
3819 /// 64-aligned `bytesPerRow`) that exceeds the new format's natural row
3820 /// stride — i.e. a reused max-sized pool tensor reconfigured to a smaller
3821 /// image — the physical pitch is preserved as the tensor's `row_stride`.
3822 /// This keeps the **physical grid** (allocation stride/surface) fixed while
3823 /// the **logical ROI** (this image's W×H) changes, so the decode writes rows
3824 /// at the surface's real stride and the GPU samples them at the same stride.
3825 /// Exact-sized buffers (pitch == natural) stay tightly packed unchanged.
3826 pub fn configure_image(
3827 &mut self,
3828 width: usize,
3829 height: usize,
3830 format: PixelFormat,
3831 ) -> Result<()> {
3832 let shape = format.allocation_shape(width, height).ok_or_else(|| {
3833 Error::InvalidArgument(format!(
3834 "invalid dimensions {width}x{height} for format {format:?}"
3835 ))
3836 })?;
3837 // Capture the pre-existing row stride before `set_format` clears it.
3838 // For pool tensors that were allocated at a larger width (e.g. 1920-wide
3839 // pool decoding a 789-wide image), this preserves the backing pitch so
3840 // rows are still written at the correct physical stride.
3841 let prior_stride = self.row_stride;
3842
3843 self.storage.set_logical_shape(&shape)?;
3844 self.set_format(format)?; // clears any stale row_stride
3845
3846 // Restore the correct row pitch for the new geometry. A DMA buffer of
3847 // ANY layout, and a semi-planar buffer on any backing, MUST carry a
3848 // 64-byte-aligned pitch: Mali/Vivante reject a DMA-BUF EGLImage whose
3849 // row pitch is not 64-aligned (`EGL_BAD_ALLOC` / `EGL_BAD_ACCESS`), and
3850 // semi-planar chroma-offset math assumes it. This mirrors the pitch
3851 // `Tensor::image()` allocates, so a recycled pool buffer imports
3852 // identically to a fresh one — without it a `configure_image()`'d packed
3853 // buffer (e.g. Y800 96-wide → tight pitch 96) failed convert() on
3854 // imx95/imx8mp via the texture-upload fallback while the fresh oracle
3855 // (aligned 128) succeeded. Packed/planar on host-only memory (Mem/Shm)
3856 // keep the tight pitch so flat CPU consumers stay unaffected — matching
3857 // `image()`'s `host_stride` rule.
3858 let elem = std::mem::size_of::<T>();
3859 let channels = format.channels();
3860 let (min_stride, total_rows) = match format.layout() {
3861 PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
3862 PixelLayout::Packed => (width * channels * elem, height),
3863 PixelLayout::Planar => (width * elem, channels * height),
3864 };
3865 let needs_align = self.storage.memory() == TensorMemory::DmaBuf
3866 || format.layout() == PixelLayout::SemiPlanar;
3867
3868 let active_stride = if let Some(pitch) = self.storage.backing_row_stride() {
3869 // macOS IOSurface: use the surface's native pitch.
3870 let natural = self.effective_row_stride().unwrap_or(0);
3871 if pitch > natural {
3872 self.set_row_stride_unchecked(pitch);
3873 pitch
3874 } else {
3875 natural
3876 }
3877 } else if needs_align {
3878 // Priority:
3879 // 1. Prior stride (pool reuse): if the pre-existing stride is
3880 // 64-aligned, >= this layout's minimum row, and fits the
3881 // allocation, keep it. This is the hot-loop reuse case (large
3882 // pool, small image).
3883 // 2. Compute a fresh 64-aligned stride for the current width.
3884 let aligned = min_stride.next_multiple_of(64);
3885 let capacity = self.storage.capacity_bytes();
3886
3887 let candidate = if let Some(ps) = prior_stride {
3888 if ps >= min_stride && ps % 64 == 0 && ps * total_rows <= capacity {
3889 ps
3890 } else {
3891 aligned
3892 }
3893 } else {
3894 aligned
3895 };
3896
3897 if candidate * total_rows <= capacity {
3898 self.set_row_stride_unchecked(candidate);
3899 candidate
3900 } else {
3901 // Shouldn't happen for legitimate pools, but don't crash.
3902 self.effective_row_stride().unwrap_or(0)
3903 }
3904 } else {
3905 self.effective_row_stride().unwrap_or(0)
3906 };
3907
3908 // Ensure the active stride fits the allocation. A pool reconfigured to a
3909 // wider image than its backing would silently SIGBUS on any subsequent
3910 // map/write — catch it here instead.
3911 if needs_align && active_stride > 0 {
3912 let needed = active_stride * total_rows;
3913 let capacity = self.storage.capacity_bytes();
3914 if needed > capacity {
3915 return Err(Error::InsufficientCapacity { needed, capacity });
3916 }
3917 }
3918 Ok(())
3919 }
3920
3921 /// Allocate an image tensor sized to hold up to `width`×`height` in
3922 /// `format`, reusable for any smaller image via `configure_image`.
3923 pub fn image_with_capacity(
3924 width: usize,
3925 height: usize,
3926 format: PixelFormat,
3927 memory: Option<TensorMemory>,
3928 access: CpuAccess,
3929 ) -> Result<Self>
3930 where
3931 T: 'static,
3932 {
3933 Self::image(width, height, format, memory, access)
3934 }
3935
3936 /// Pixel format (None if not an image).
3937 pub fn format(&self) -> Option<PixelFormat> {
3938 self.format
3939 }
3940
3941 /// Image width (None if not an image).
3942 pub fn width(&self) -> Option<usize> {
3943 let fmt = self.format?;
3944 let shape = self.shape();
3945 match fmt.layout() {
3946 PixelLayout::Packed => Some(shape[1]),
3947 PixelLayout::Planar => Some(shape[2]),
3948 PixelLayout::SemiPlanar => Some(shape[1]),
3949 }
3950 }
3951
3952 /// Image height (None if not an image).
3953 ///
3954 /// For semi-planar formats the combined-plane shape row count is divided
3955 /// by the format's luma-to-total ratio to recover logical height. This
3956 /// returns the exact logical height (including odd heights) only because
3957 /// the logical dimensions are tracked separately from the physical shape —
3958 /// `configure_image` stores the actual `(width, height)` in the format's
3959 /// `allocation_shape`, which round-trips losslessly via these accessors.
3960 pub fn height(&self) -> Option<usize> {
3961 let fmt = self.format?;
3962 let shape = self.shape();
3963 match fmt.layout() {
3964 PixelLayout::Packed => Some(shape[0]),
3965 PixelLayout::Planar => Some(shape[1]),
3966 PixelLayout::SemiPlanar => {
3967 if self.is_multiplane() {
3968 Some(shape[0])
3969 } else {
3970 match fmt {
3971 PixelFormat::Nv12 => Some(shape[0] * 2 / 3),
3972 PixelFormat::Nv16 => Some(shape[0] / 2),
3973 PixelFormat::Nv24 => Some(shape[0] / 3),
3974 _ => None,
3975 }
3976 }
3977 }
3978 }
3979 }
3980
3981 /// Create from separate Y and UV planes (multiplane NV12/NV16).
3982 pub fn from_planes(luma: Tensor<T>, chroma: Tensor<T>, format: PixelFormat) -> Result<Self> {
3983 if format.layout() != PixelLayout::SemiPlanar {
3984 return Err(Error::InvalidArgument(format!(
3985 "from_planes requires a semi-planar format, got {format:?}"
3986 )));
3987 }
3988 if chroma.format.is_some() || chroma.chroma.is_some() {
3989 return Err(Error::InvalidArgument(
3990 "chroma tensor must be a raw tensor (no format or chroma metadata)".into(),
3991 ));
3992 }
3993 let luma_shape = luma.shape();
3994 let chroma_shape = chroma.shape();
3995 if luma_shape.len() != 2 || chroma_shape.len() != 2 {
3996 return Err(Error::InvalidArgument(format!(
3997 "from_planes expects 2D shapes, got luma={luma_shape:?} chroma={chroma_shape:?}"
3998 )));
3999 }
4000 if luma_shape[1] != chroma_shape[1] {
4001 return Err(Error::InvalidArgument(format!(
4002 "luma width {} != chroma width {}",
4003 luma_shape[1], chroma_shape[1]
4004 )));
4005 }
4006 match format {
4007 PixelFormat::Nv12 => {
4008 if luma_shape[0] % 2 != 0 {
4009 return Err(Error::InvalidArgument(format!(
4010 "NV12 requires even luma height, got {}",
4011 luma_shape[0]
4012 )));
4013 }
4014 if chroma_shape[0] != luma_shape[0] / 2 {
4015 return Err(Error::InvalidArgument(format!(
4016 "NV12 chroma height {} != luma height / 2 ({})",
4017 chroma_shape[0],
4018 luma_shape[0] / 2
4019 )));
4020 }
4021 }
4022 PixelFormat::Nv16 => {
4023 if chroma_shape[0] != luma_shape[0] {
4024 return Err(Error::InvalidArgument(format!(
4025 "NV16 chroma height {} != luma height {}",
4026 chroma_shape[0], luma_shape[0]
4027 )));
4028 }
4029 }
4030 // NV24's chroma plane is full-resolution (2×-wide interleaved UV),
4031 // which the equal-width plane check above doesn't model. Multiplane
4032 // NV24 is unused (the JPEG decoder emits a contiguous NV24 buffer),
4033 // so it's not supported here yet.
4034 _ => {
4035 return Err(Error::InvalidArgument(format!(
4036 "from_planes only supports NV12 and NV16 (NV24 multiplane not yet \
4037 supported — use a contiguous NV24 tensor), got {format:?}"
4038 )));
4039 }
4040 }
4041
4042 Ok(Tensor {
4043 storage: luma.storage,
4044 format: Some(format),
4045 chroma: Some(Box::new(chroma)),
4046 row_stride: luma.row_stride,
4047 plane_offset: luma.plane_offset,
4048 quantization: luma.quantization,
4049 // A multiplane tensor spans two DMA-BUFs (luma + chroma); CUDA
4050 // external-memory import is per-fd, so there is no single device
4051 // pointer for the composite. Any CUDA handle the luma plane carried
4052 // is intentionally dropped — consumers needing CUDA access to
4053 // multiplane data must import each plane independently.
4054 cuda: None,
4055 colorimetry: luma.colorimetry,
4056 cpu_access: luma.cpu_access,
4057 compression: luma.compression,
4058 // A composed multiplane tensor is a whole image, not a sub-view.
4059 view_origin: None,
4060 })
4061 }
4062
4063 /// Whether this tensor uses separate plane allocations.
4064 pub fn is_multiplane(&self) -> bool {
4065 self.chroma.is_some()
4066 }
4067
4068 /// Access the chroma plane for multiplane semi-planar images.
4069 pub fn chroma(&self) -> Option<&Tensor<T>> {
4070 self.chroma.as_deref()
4071 }
4072
4073 /// Mutable access to the chroma plane for multiplane semi-planar images.
4074 pub fn chroma_mut(&mut self) -> Option<&mut Tensor<T>> {
4075 self.chroma.as_deref_mut()
4076 }
4077
4078 /// Row stride in bytes (`None` = tightly packed).
4079 pub fn row_stride(&self) -> Option<usize> {
4080 self.row_stride
4081 }
4082
4083 /// Effective row stride in bytes: the stored stride if set, otherwise the
4084 /// minimum stride computed from the format, width, and element size.
4085 /// Returns `None` only when no format is set and no explicit stride was
4086 /// stored via [`set_row_stride`](Self::set_row_stride).
4087 ///
4088 /// **GREY note:** `effective_row_stride()` for a GREY tensor returns the
4089 /// tight `width` bytes (no padding), which is what `normalize_to_numpy` and
4090 /// the CPU convert path expect. The codec's internal `native_row_stride`
4091 /// (64-byte-aligned) is used only during decoding and is not propagated to
4092 /// the tensor's stored stride, so callers reading via
4093 /// `effective_row_stride()` always see the tight value for GREY.
4094 pub fn effective_row_stride(&self) -> Option<usize> {
4095 if let Some(s) = self.row_stride {
4096 return Some(s);
4097 }
4098 let fmt = self.format?;
4099 let w = self.width()?;
4100 let elem = std::mem::size_of::<T>();
4101 Some(match fmt.layout() {
4102 PixelLayout::Packed => w * fmt.channels() * elem,
4103 PixelLayout::Planar => w * elem,
4104 // Semi-planar: minimum stride must cover the even width so the
4105 // interleaved chroma columns are byte-aligned on odd-width images.
4106 PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4107 })
4108 }
4109
4110 /// Copy the tensor's logical bytes into `dst`, compacting away any
4111 /// recorded row-stride padding.
4112 ///
4113 /// The flatness helper for NPU handoff: consumers that need a FLAT
4114 /// layout (e.g. `[1, C, H, W]` for NNAPI/LiteRT when the runtime cannot
4115 /// take a padded pitch) call this when [`row_stride`](Self::row_stride)
4116 /// is `Some` — on a tight tensor it degenerates to one memcpy, so it is
4117 /// safe to call unconditionally. `dst.len()` must equal the tight byte
4118 /// footprint (`shape` product × element size). Zero-copy consumers
4119 /// should prefer the buffer handle + [`Tensor::effective_row_stride`]
4120 /// (Self::effective_row_stride) and skip this copy entirely.
4121 pub fn copy_to_flat(&self, dst: &mut [u8]) -> Result<()> {
4122 let tight_bytes = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(self.shape())?;
4123 if dst.len() != tight_bytes {
4124 return Err(Error::InvalidArgument(format!(
4125 "copy_to_flat: dst is {} bytes but the tensor's tight \
4126 footprint is {tight_bytes} bytes (shape {:?})",
4127 dst.len(),
4128 self.shape()
4129 )));
4130 }
4131 let map = self.map()?;
4132 // SAFETY: T is a plain numeric type (crate-wide bound); viewing the
4133 // mapped elements as bytes is sound.
4134 let src: &[u8] = unsafe {
4135 std::slice::from_raw_parts(
4136 map.as_slice().as_ptr() as *const u8,
4137 std::mem::size_of_val(map.as_slice()),
4138 )
4139 };
4140 let Some(stride) = self.row_stride else {
4141 // Tight layout: the mapped window is exactly the logical bytes.
4142 let got = src.len().min(tight_bytes);
4143 if got < tight_bytes {
4144 return Err(Error::InvalidOperation(format!(
4145 "copy_to_flat: mapped {got} bytes < tight footprint {tight_bytes}"
4146 )));
4147 }
4148 dst.copy_from_slice(&src[..tight_bytes]);
4149 return Ok(());
4150 };
4151 // Strided: row count follows the strided-map convention (planar
4152 // stacks C planes of H rows; packed/semi-planar use shape[0]), and
4153 // the logical row is tight_bytes / rows for every layout.
4154 let rows = match self.format.map(|f| f.layout()) {
4155 Some(PixelLayout::Planar) => {
4156 let s = self.shape();
4157 if s.len() < 2 {
4158 return Err(Error::InvalidOperation(
4159 "copy_to_flat: strided planar tensor requires [C, H, W] shape".into(),
4160 ));
4161 }
4162 s[0].checked_mul(s[1]).ok_or_else(|| {
4163 Error::InvalidOperation(format!(
4164 "copy_to_flat: planar rows {} × {} overflows usize",
4165 s[0], s[1]
4166 ))
4167 })?
4168 }
4169 _ => *self.shape().first().ok_or_else(|| {
4170 Error::InvalidOperation("copy_to_flat: tensor has an empty shape".into())
4171 })?,
4172 };
4173 if rows == 0 || !tight_bytes.is_multiple_of(rows) {
4174 return Err(Error::InvalidOperation(format!(
4175 "copy_to_flat: tight footprint {tight_bytes} does not divide \
4176 into {rows} rows"
4177 )));
4178 }
4179 let row_bytes = tight_bytes / rows;
4180 let need = (rows - 1)
4181 .checked_mul(stride)
4182 .and_then(|b| b.checked_add(row_bytes))
4183 .ok_or_else(|| {
4184 Error::InvalidOperation(format!(
4185 "copy_to_flat: stride {stride} × rows {rows} overflows usize"
4186 ))
4187 })?;
4188 if src.len() < need {
4189 return Err(Error::InvalidOperation(format!(
4190 "copy_to_flat: mapped {} bytes but strided rows need {need}",
4191 src.len()
4192 )));
4193 }
4194 for r in 0..rows {
4195 dst[r * row_bytes..(r + 1) * row_bytes]
4196 .copy_from_slice(&src[r * stride..r * stride + row_bytes]);
4197 }
4198 Ok(())
4199 }
4200
4201 /// Set the row stride in bytes for externally allocated buffers with
4202 /// row padding (e.g. V4L2 or GStreamer allocators).
4203 ///
4204 /// The stride is propagated to the EGL DMA-BUF import attributes so
4205 /// the GPU interprets the padded buffer layout correctly. Must be
4206 /// called after [`set_format`](Self::set_format) and before the tensor
4207 /// is first passed to `ImageProcessor::convert`. The stored stride
4208 /// is cleared automatically if the pixel format is later changed.
4209 ///
4210 /// No stride-vs-buffer-size validation is performed because the
4211 /// backing allocation size is not reliably known: external DMA-BUFs
4212 /// may be over-allocated by the allocator, and internal tensors store
4213 /// a logical (unpadded) shape. An incorrect stride will be caught by
4214 /// the EGL driver at import time.
4215 ///
4216 /// # Arguments
4217 ///
4218 /// * `stride` - Row stride in bytes. Must be >= the minimum stride for
4219 /// the format (width * channels * sizeof(T) for packed,
4220 /// width * sizeof(T) for planar/semi-planar).
4221 ///
4222 /// # Errors
4223 ///
4224 /// * `InvalidArgument` if no pixel format is set on this tensor
4225 /// * `InvalidArgument` if `stride` is less than the minimum for the
4226 /// format and width
4227 pub fn set_row_stride(&mut self, stride: usize) -> Result<()> {
4228 let fmt = self.format.ok_or_else(|| {
4229 Error::InvalidArgument("cannot set row_stride without a pixel format".into())
4230 })?;
4231 let w = self.width().ok_or_else(|| {
4232 Error::InvalidArgument("cannot determine width for row_stride validation".into())
4233 })?;
4234 let elem = std::mem::size_of::<T>();
4235 let min_stride = match fmt.layout() {
4236 PixelLayout::Packed => w * fmt.channels() * elem,
4237 PixelLayout::Planar => w * elem,
4238 // Semi-planar: minimum must cover even width for chroma alignment.
4239 PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4240 };
4241 if stride < min_stride {
4242 return Err(Error::InvalidArgument(format!(
4243 "row_stride {stride} < minimum {min_stride} for {fmt:?} at width {w}"
4244 )));
4245 }
4246 self.row_stride = Some(stride);
4247 Ok(())
4248 }
4249
4250 /// Set the row stride without format validation.
4251 ///
4252 /// Use this for raw sub-tensors (e.g. chroma planes) that don't carry
4253 /// format metadata. The caller is responsible for ensuring the stride
4254 /// is valid.
4255 pub fn set_row_stride_unchecked(&mut self, stride: usize) {
4256 self.row_stride = Some(stride);
4257 }
4258
4259 /// Builder-style variant of [`set_row_stride`](Self::set_row_stride),
4260 /// consuming and returning `self`.
4261 ///
4262 /// # Errors
4263 ///
4264 /// Same conditions as [`set_row_stride`](Self::set_row_stride).
4265 pub fn with_row_stride(mut self, stride: usize) -> Result<Self> {
4266 self.set_row_stride(stride)?;
4267 Ok(self)
4268 }
4269
4270 /// Byte offset within the DMA-BUF where image data starts (`None` = 0).
4271 pub fn plane_offset(&self) -> Option<usize> {
4272 self.plane_offset
4273 }
4274
4275 /// The parent-image snapshot if this tensor is a [`view`](Self::view)/
4276 /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. The GL
4277 /// backend keys its import on the parent geometry and renders this view as a
4278 /// `glViewport`/`glScissor` ROI at `(x, y, width, height)`. See [`ViewOrigin`].
4279 pub fn view_origin(&self) -> Option<ViewOrigin> {
4280 self.view_origin
4281 }
4282
4283 /// Set the byte offset within the DMA-BUF where image data starts.
4284 ///
4285 /// Propagated to `EGL_DMA_BUF_PLANE0_OFFSET_EXT` on GPU import.
4286 /// Unlike [`set_row_stride`](Self::set_row_stride), no format is required
4287 /// since the offset is format-independent.
4288 pub fn set_plane_offset(&mut self, offset: usize) {
4289 self.plane_offset = Some(offset);
4290 // The offset consulted by `map()` lives inside the storage variant.
4291 // Keep it in sync with the wrapper field for every backing that
4292 // honors it (DMA and Mem); see also the clear sites in `set_format`
4293 // and `reshape`.
4294 match self.storage {
4295 TensorStorage::Mem(ref mut m) => m.set_offset(offset),
4296 #[cfg(target_os = "linux")]
4297 TensorStorage::Dma(ref mut dma) => dma.mmap_offset = offset,
4298 _ => {}
4299 }
4300 }
4301
4302 /// Colorimetry metadata (`None` = undefined; never auto-filled).
4303 /// The CPU access declared for this tensor at allocation (see
4304 /// [`CpuAccess`]). Views share their parent's declaration.
4305 pub fn cpu_access(&self) -> CpuAccess {
4306 self.cpu_access
4307 }
4308
4309 /// Set the declared CPU access without re-allocating — crate-private:
4310 /// the declaration must reflect the underlying allocation's real
4311 /// capabilities (constructors and importers set it; arbitrary widening
4312 /// would defeat the contract).
4313 // Only the Android AHardwareBuffer importer derives a declaration from
4314 // an existing allocation's usage bits today.
4315 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
4316 pub(crate) fn set_cpu_access_unchecked(&mut self, access: CpuAccess) {
4317 self.cpu_access = access;
4318 }
4319
4320 /// The vendor tile-compression scheme recorded at allocation, or
4321 /// `None` for a linear layout. `Some` means the pixels live in a
4322 /// proprietary tile order: the row-stride accessors describe no
4323 /// meaningful linear layout and CPU maps are best-effort (see
4324 /// [`Compression`]). Only Android hardware-only allocations that
4325 /// requested compression record a scheme.
4326 pub fn compression(&self) -> Option<CompressionScheme> {
4327 self.compression
4328 }
4329
4330 /// Record the compression scheme — crate-private: recording is an
4331 /// allocation-time fact ([`Tensor::image_desc`] sets it; arbitrary
4332 /// mutation would misdescribe the physical layout).
4333 // Only the Android allocation path records a scheme today.
4334 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
4335 pub(crate) fn set_compression_unchecked(&mut self, scheme: Option<CompressionScheme>) {
4336 self.compression = scheme;
4337 }
4338
4339 pub fn colorimetry(&self) -> Option<crate::Colorimetry> {
4340 self.colorimetry
4341 }
4342
4343 /// Attach/clear colorimetry metadata.
4344 pub fn set_colorimetry(&mut self, c: Option<crate::Colorimetry>) {
4345 self.colorimetry = c;
4346 }
4347
4348 /// Builder-style colorimetry attach.
4349 pub fn with_colorimetry(mut self, c: crate::Colorimetry) -> Self {
4350 self.colorimetry = Some(c);
4351 self
4352 }
4353
4354 /// Create a zero-copy sub-region view of this tensor's backing buffer.
4355 ///
4356 /// The returned tensor shares this tensor's allocation (no copy) and maps
4357 /// the window `[offset_bytes, offset_bytes + shape.product()*size_of::<T>())`
4358 /// measured from this tensor's own logical start. N sub-views into one
4359 /// parent can be written independently, enabling batched assembly into a
4360 /// single buffer. Identical semantics across `Mem` (shared `Arc`) and
4361 /// `Dma` (shared fd) backings.
4362 ///
4363 /// # Disjointness
4364 ///
4365 /// Independent writes are sound *only* when the windows do not overlap. The
4366 /// shared backing uses interior mutability (`UnsafeCell` cells), so two
4367 /// sub-views whose byte ranges intersect alias the same cells: writing one
4368 /// while reading or writing the other is a data race and therefore
4369 /// **undefined behaviour**. The caller is responsible for keeping the
4370 /// windows disjoint; this method does not check for overlap.
4371 ///
4372 /// # Errors
4373 ///
4374 /// - [`Error::InvalidOperation`] if the backing is not `Mem` or `Dma`, or
4375 /// if `offset_bytes` is not a multiple of `align_of::<T>()`.
4376 /// - [`Error::InsufficientCapacity`] / [`Error::InvalidSize`] if the window
4377 /// exceeds the parent allocation.
4378 pub(crate) fn subview(&self, offset_bytes: usize, shape: &[usize]) -> Result<Tensor<T>> {
4379 // Offset is absolute into the backing allocation: a sub-view of a
4380 // sub-view composes by adding this tensor's own offset.
4381 let abs_offset = self
4382 .plane_offset
4383 .unwrap_or(0)
4384 .checked_add(offset_bytes)
4385 .ok_or(Error::InvalidSize(offset_bytes))?;
4386 // Every backend exposes `view(offset, shape)` via `TensorTrait`, sharing
4387 // the resource AND `BufferIdentity` (unlike `from_fd`/`from_surface`,
4388 // which mint a fresh identity). The GL backend keys the import on the
4389 // shared identity so offset-distinct sub-views of one buffer reuse a
4390 // single import and address their window via `glViewport`. `Mem`/`Shm`
4391 // share via the allocation `Arc` / a cloned fd; `Pbo` via the GL-buffer
4392 // `Arc`; Linux DMA-BUF / macOS IOSurface via the shared fd / CFRetain.
4393 // `TensorStorage::view` performs the one remaining per-variant dispatch.
4394 let mut t = Tensor::wrap(self.storage.view(offset_bytes, shape)?);
4395 // Inherit the parent's image metadata so the view is a ready-to-use
4396 // sub-image (e.g. a `convert()` destination). The offset is applied
4397 // LAST because `set_format` deliberately clears it — the offset is a
4398 // structural property of the sub-region, not format-dependent metadata.
4399 if let Some(fmt) = self.format {
4400 t.set_format(fmt)?;
4401 }
4402 if let Some(rs) = self.row_stride {
4403 t.set_row_stride_unchecked(rs);
4404 }
4405 t.quantization = self.quantization.clone();
4406 // A sub-region of an image carries the parent's colorimetry — it is the
4407 // same pixels, same color encoding. Inherit it like the other image
4408 // metadata above so a sub-view is a faithful convert() source/target.
4409 t.set_colorimetry(self.colorimetry);
4410 // The declared CPU access is a property of the underlying
4411 // allocation, so every view shares the parent's declaration.
4412 t.cpu_access = self.cpu_access;
4413 // Likewise the recorded compression scheme: the view shares the
4414 // parent's physical layout.
4415 t.compression = self.compression;
4416 if abs_offset > 0 {
4417 t.set_plane_offset(abs_offset);
4418 }
4419 Ok(t)
4420 }
4421
4422 /// Borrow batch element `n` of a batched tensor as a zero-copy view.
4423 ///
4424 /// A batched tensor prepends `N` as the leading dimension over the
4425 /// per-element image layout (`[N, H, W, C]` packed or `[N, C, H, W]`
4426 /// planar) — `N` is over the whole per-element block regardless of
4427 /// `HWC`/`CHW`. `batch(n)` returns element `n`: the contiguous per-element
4428 /// region at byte offset `n * element_size`, sharing the parent's
4429 /// `BufferIdentity` and inheriting its format / row stride / colorimetry.
4430 /// `batch(0)` on a tensor with `N == 1` is equivalent to the whole tensor.
4431 ///
4432 /// # Errors
4433 ///
4434 /// - [`Error::BatchIndexOutOfBounds`] if `n >= N`.
4435 /// - [`Error::InvalidShape`] if the tensor is not batched (a formatted
4436 /// tensor whose rank lacks the leading `N`, or an empty shape).
4437 pub fn batch(&self, n: usize) -> Result<Tensor<T>> {
4438 let shape = self.shape();
4439 // With a format we know the exact per-element rank, so a missing leading
4440 // `N` is a misuse we reject rather than silently treating a spatial dim
4441 // as the batch. Raw tensors take shape[0] as `N` by contract.
4442 if let Some(fmt) = self.format {
4443 let elem_rank = match fmt.layout() {
4444 PixelLayout::SemiPlanar => 2,
4445 _ => 3,
4446 };
4447 if shape.len() != elem_rank + 1 {
4448 return Err(Error::InvalidShape(format!(
4449 "batch(): tensor is not batched ({fmt:?} expects a leading N over a \
4450 {elem_rank}-D element, got shape {shape:?})"
4451 )));
4452 }
4453 }
4454 let batch = *shape
4455 .first()
4456 .ok_or_else(|| Error::InvalidShape("batch(): empty shape".into()))?;
4457 if n >= batch {
4458 return Err(Error::BatchIndexOutOfBounds { index: n, batch });
4459 }
4460 let elem_shape: Vec<usize> = shape[1..].to_vec();
4461 let elem_count: usize = elem_shape.iter().product();
4462 let elem_bytes = elem_count
4463 .checked_mul(std::mem::size_of::<T>())
4464 .ok_or(Error::InvalidSize(elem_count))?;
4465 let offset = n.checked_mul(elem_bytes).ok_or(Error::InvalidSize(n))?;
4466 // For a packed `[N, H, W, C]` tensor the N tiles stack vertically in the
4467 // shared buffer, so the GL import sees one `(W, N*H)` parent and each
4468 // tile is the row-band at `y = n*H`. Snapshot that parent so the backend
4469 // imports once and renders the tile via `glViewport`. Non-packed
4470 // (planar/semi-planar) batching keeps the per-slot path for now (planar
4471 // NCHW tiling is a separate step); raw tensors have no pixel geometry.
4472 let view_origin = match self.format.map(|f| f.layout()) {
4473 Some(PixelLayout::Packed) => {
4474 let tile_h = elem_shape[0];
4475 let tile_w = elem_shape[1];
4476 // Per-row pitch of the tall `(W, N*H)` parent — padded stride if
4477 // set, else the tight row width. The GL import keys on this.
4478 let bpp = elem_shape[2] * std::mem::size_of::<T>();
4479 let parent_stride = self.effective_row_stride().unwrap_or(tile_w * bpp);
4480 Some(self.compose_view_origin(tile_w, batch * tile_h, parent_stride, 0, n * tile_h))
4481 }
4482 _ => None,
4483 };
4484 let mut t = self.subview(offset, &elem_shape)?;
4485 t.view_origin = view_origin;
4486 Ok(t)
4487 }
4488
4489 /// Borrow a rectangular spatial sub-region of an image tensor as a
4490 /// zero-copy view — the **destination/source crop** primitive.
4491 ///
4492 /// `region` is in pixels of the image's leading frame. The returned view
4493 /// shares the parent's `BufferIdentity` and addresses the sub-rectangle by
4494 /// offset + the **parent's** row pitch (so each row lands at the correct
4495 /// columns). `convert(src, &mut dst.view(rect), …)` renders into that
4496 /// sub-rectangle of `dst`; a letterbox fit then clears the view and renders
4497 /// the aspect-preserved content into its inner region. `view`/`batch`/the
4498 /// whole tensor are the one coherent destination model — there is no
4499 /// separate `dst_rect`.
4500 ///
4501 /// # Known limitation: bottom-right multi-row views
4502 ///
4503 /// A multi-row view is mapped as `parent_pitch × rows` bytes **from its own
4504 /// offset**, so the window it asks for overruns the allocation by
4505 /// `region.x * bpp` whenever the view is both offset horizontally *and*
4506 /// reaches the parent's last row. Such a view constructs fine but fails at
4507 /// [`map`](TensorTrait::map) time with [`Error::InsufficientCapacity`] —
4508 /// loudly, never as an out-of-bounds access. For a tiled destination this is
4509 /// the bottom row of tiles at every column but the first. The single-row
4510 /// case is already special-cased below (it keeps its own tight stride); the
4511 /// general fix is to clamp the last row's exposure to the view's own row
4512 /// bytes, which is not yet implemented.
4513 ///
4514 /// # Errors
4515 ///
4516 /// - [`Error::RegionOutOfBounds`] if `region` exceeds the image bounds.
4517 /// - [`Error::InvalidOperation`] if the tensor is not a packed-format image
4518 /// (planar/semi-planar spatial sub-rects are not a single strided window;
4519 /// use [`batch`](Self::batch) for batched planar tensors).
4520 pub fn view(&self, region: Region) -> Result<Tensor<T>> {
4521 let fmt = self.format.ok_or_else(|| {
4522 Error::InvalidOperation("view() requires a formatted image tensor".into())
4523 })?;
4524 if fmt.layout() != PixelLayout::Packed {
4525 return Err(Error::InvalidOperation(format!(
4526 "view() supports packed formats only (got {fmt:?}); use batch(n) for batched \
4527 planar tensors"
4528 )));
4529 }
4530 let w = self
4531 .width()
4532 .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image width".into()))?;
4533 let h = self
4534 .height()
4535 .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image height".into()))?;
4536 if !region.fits_within(w, h) {
4537 return Err(Error::RegionOutOfBounds {
4538 region,
4539 bounds: (w, h),
4540 });
4541 }
4542 let elem = std::mem::size_of::<T>();
4543 let bpp = fmt.channels() * elem;
4544 let stride = self.effective_row_stride().unwrap_or(w * bpp);
4545 let offset = region
4546 .y
4547 .checked_mul(stride)
4548 .and_then(|yo| yo.checked_add(region.x.checked_mul(bpp)?))
4549 .ok_or(Error::InvalidSize(region.y))?;
4550 let sub_shape = fmt
4551 .allocation_shape(region.width, region.height)
4552 .ok_or_else(|| Error::InvalidShape(format!("view(): invalid shape for {fmt:?}")))?;
4553 let mut t = self.subview(offset, &sub_shape)?;
4554 // A multi-row sub-rect must advance rows by the PARENT pitch so each row
4555 // addresses the correct columns. A single-row view uses its own tight
4556 // stride — the parent pitch would make the strided `map()` (which exposes
4557 // `stride × rows`) expose a trailing row that runs past the buffer tail
4558 // for an offset (x>0 / bottom) view. The GL backend does NOT rely on this
4559 // (single-row-tight) `row_stride`: it reads the parent pitch from
4560 // `view_origin.parent_row_stride` so its import/cache pitch stays
4561 // parent-consistent for views of any height — see `ViewOrigin`.
4562 let view_stride = if region.height > 1 {
4563 stride
4564 } else {
4565 region.width * bpp
4566 };
4567 t.set_row_stride_unchecked(view_stride);
4568 // Snapshot the parent `(w, h, row_stride)` so the GL backend imports the
4569 // parent once (keyed on the parent pitch, not this view's possibly-tight
4570 // single-row stride) and renders this sub-rect as a `glViewport`/
4571 // `glScissor` ROI at `(region.x, region.y)`. Composes when viewing an
4572 // existing view.
4573 t.view_origin = Some(self.compose_view_origin(w, h, stride, region.x, region.y));
4574 Ok(t)
4575 }
4576
4577 /// Build the [`ViewOrigin`] for a new sub-region of `self`. When `self` is a
4578 /// whole tensor the snapshot names `self` as the parent; when `self` is
4579 /// already a view, the snapshot keeps the **root** parent and accumulates the
4580 /// local origin so nested views still resolve to one import.
4581 fn compose_view_origin(
4582 &self,
4583 parent_width: usize,
4584 parent_height: usize,
4585 parent_row_stride: usize,
4586 x: usize,
4587 y: usize,
4588 ) -> ViewOrigin {
4589 match self.view_origin {
4590 Some(root) => ViewOrigin {
4591 parent_width: root.parent_width,
4592 parent_height: root.parent_height,
4593 parent_row_stride: root.parent_row_stride,
4594 x: root.x.saturating_add(x),
4595 y: root.y.saturating_add(y),
4596 },
4597 None => ViewOrigin {
4598 parent_width,
4599 parent_height,
4600 parent_row_stride,
4601 x,
4602 y,
4603 },
4604 }
4605 }
4606
4607 /// Downcast to PBO tensor reference (for GL backends).
4608 pub fn as_pbo(&self) -> Option<&PboTensor<T>> {
4609 match &self.storage {
4610 TensorStorage::Pbo(p) => Some(p),
4611 _ => None,
4612 }
4613 }
4614
4615 /// The GL buffer ID backing this tensor (for GL backends / the
4616 /// cross-package protocol's `handle`). `None` when the tensor is not
4617 /// PBO-backed.
4618 pub fn pbo_id(&self) -> Option<u32> {
4619 self.as_pbo().map(PboTensor::buffer_id)
4620 }
4621
4622 /// The C-ABI `PboOpsVtable` address backing this tensor (for the
4623 /// cross-package protocol's `ptr` under `kind::PBO`; see
4624 /// [`crate::protocol::TensorDesc::ptr`]'s doc comment). `None` when the
4625 /// tensor is not PBO-backed.
4626 pub fn pbo_vtable_ptr(&self) -> Option<*const std::ffi::c_void> {
4627 self.as_pbo()
4628 .map(|p| p.pbo_vtable() as *const _ as *const std::ffi::c_void)
4629 }
4630
4631 /// A type-erased keepalive that must stay alive for at least as long as
4632 /// [`Self::pbo_vtable_ptr`]'s address is used -- see
4633 /// [`PboTensor::pbo_keepalive`]'s own doc comment. `None` when the
4634 /// tensor is not PBO-backed.
4635 pub fn pbo_keepalive(&self) -> Option<Arc<dyn Send + Sync>> {
4636 self.as_pbo().map(PboTensor::pbo_keepalive)
4637 }
4638
4639 /// Downcast to DMA tensor reference (for EGL import, G2D).
4640 #[cfg(target_os = "linux")]
4641 pub fn as_dma(&self) -> Option<&DmaTensor<T>> {
4642 match &self.storage {
4643 TensorStorage::Dma(d) => Some(d),
4644 _ => None,
4645 }
4646 }
4647
4648 /// Borrow the DMA-BUF file descriptor backing this tensor.
4649 ///
4650 /// # Returns
4651 ///
4652 /// A borrowed reference to the DMA-BUF file descriptor, tied to `self`'s
4653 /// lifetime.
4654 ///
4655 /// # Errors
4656 ///
4657 /// Returns `Error::NotImplemented` if the tensor is not DMA-backed.
4658 #[cfg(target_os = "linux")]
4659 pub fn dmabuf(&self) -> Result<std::os::fd::BorrowedFd<'_>> {
4660 use std::os::fd::AsFd;
4661 match &self.storage {
4662 TensorStorage::Dma(dma) => Ok(dma.fd.as_fd()),
4663 _ => Err(Error::NotImplemented(format!(
4664 "dmabuf requires DMA-backed tensor, got {:?}",
4665 self.storage.memory()
4666 ))),
4667 }
4668 }
4669
4670 /// Construct a Tensor from a PBO tensor (for GL backends that allocate PBOs).
4671 ///
4672 /// Infallible in this backend (`Ok` always) -- kept as `Result` to match
4673 /// `dynamic`'s own `Tensor::from_pbo` (`dynamic_tensor.rs`), which
4674 /// genuinely can fail: it must allocate a real `ef_tensor_*` handle
4675 /// alongside `pbo` to carry shape/dtype/format metadata (this backend
4676 /// has nowhere else to put it), an allocation this backend never
4677 /// needed since it just wraps `pbo` into `TensorStorage::Pbo` directly.
4678 /// One shared, fallible signature lets `edgefirst-image`'s own call
4679 /// sites (`gl/threaded.rs`) stay backend-agnostic.
4680 pub fn from_pbo(pbo: PboTensor<T>) -> Result<Self> {
4681 Ok(Self {
4682 storage: TensorStorage::Pbo(pbo),
4683 format: None,
4684 chroma: None,
4685 row_stride: None,
4686 plane_offset: None,
4687 quantization: None,
4688 cuda: None,
4689 colorimetry: None,
4690 cpu_access: CpuAccess::ReadWrite,
4691 compression: None,
4692 view_origin: None,
4693 })
4694 }
4695
4696 /// The CUDA registration for this tensor, if any (set at creation on CUDA devices).
4697 pub fn cuda(&self) -> Option<&crate::cuda::CudaHandle> {
4698 self.cuda.as_ref()
4699 }
4700
4701 /// Attach a CUDA handle (called by ImageProcessor::create_image after registering a PBO).
4702 pub fn set_cuda_handle(&mut self, h: crate::cuda::CudaHandle) {
4703 self.cuda = Some(h);
4704 }
4705
4706 /// Fast-fail CUDA map: None (no GL routing) when no handle; else map (PBO routes to the GL worker).
4707 ///
4708 /// Returns a scoped [`CudaMap`] guard holding the raw CUDA device pointer
4709 /// for the duration of the mapping. For GL-buffer-backed tensors the unmap is deferred until the
4710 /// guard drops, freeing the PBO for the next `convert()` call. When no CUDA handle is attached
4711 /// (the common case for plain `Mem`/`DMA` tensors without CUDA registration), returns `None`
4712 /// immediately — no GL routing, no allocation.
4713 ///
4714 /// # Example — zero-copy CUDA input with host fallback
4715 ///
4716 /// ```no_run
4717 /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
4718 /// # fn feed_tensorrt(_dptr: *mut std::ffi::c_void, _bytes: usize) {}
4719 /// # fn demo(t: &Tensor<f32>) {
4720 /// // Try the zero-copy CUDA device pointer first.
4721 /// if let Some(cuda) = t.cuda_map() {
4722 /// feed_tensorrt(cuda.device_ptr(), cuda.len());
4723 /// // `cuda` (a CudaMap guard) unmaps when it goes out of scope, freeing
4724 /// // the GPU buffer for the next convert().
4725 /// } else {
4726 /// // Fall back to the host mapping when no CUDA handle is attached.
4727 /// let _host = t.map().expect("host map fallback must succeed");
4728 /// // `_host` is a TensorMap<f32> that derefs to &[f32].
4729 /// }
4730 /// # }
4731 /// ```
4732 pub fn cuda_map(&self) -> Option<crate::cuda::CudaMap<'_>> {
4733 self.cuda.as_ref()?.map()
4734 }
4735
4736 /// Attempt to attach a CUDA `ExternalMemory` handle for DMA-backed tensors.
4737 ///
4738 /// On a CUDA-capable host, imports the DMA-BUF fd via
4739 /// `cudaImportExternalMemory(OpaqueFd)` and maps it to a device pointer.
4740 /// Sets `self.cuda` to a persistent `ExternalMem` handle on success. No-op
4741 /// if CUDA is unavailable, the tensor is not DMA-backed, or a handle is
4742 /// already set. Import failure is silently ignored — the tensor remains
4743 /// usable without a CUDA handle.
4744 ///
4745 /// # RUNTIME-UNVALIDATED
4746 ///
4747 /// No test platform has both `/dev/dma_heap` and a CUDA device. ABI is
4748 /// layout-asserted vs. CUDA 12.6 `driver_types.h`; the mechanism is proven
4749 /// by gpu-probe O5 on Orin. Best-effort: tensor creation never fails here.
4750 #[cfg(target_os = "linux")]
4751 pub fn try_init_dma_cuda(&mut self) {
4752 // Fast-path: already imported, CUDA not available, or not a DMA tensor.
4753 if self.cuda.is_some() || !crate::cuda::is_cuda_available() {
4754 return;
4755 }
4756 let (raw_fd, buf_size) = match &self.storage {
4757 TensorStorage::Dma(dma) => {
4758 use std::os::fd::AsRawFd;
4759 (dma.fd.as_raw_fd(), dma.buf_size)
4760 }
4761 _ => return,
4762 };
4763 if let Some((ext, dptr)) = crate::cuda::import_dma_fd(raw_fd, buf_size) {
4764 self.cuda = Some(crate::cuda::CudaHandle::new_external(ext, dptr, buf_size));
4765 }
4766 }
4767}
4768
4769// Quantization accessors — type-gated to integer element types via the
4770// sealed `IntegerType` trait. Calling `.quantization()` on a `Tensor<f32>`
4771// produces a compile error, not a runtime one.
4772#[cfg(feature = "static")]
4773impl<T> Tensor<T>
4774where
4775 T: IntegerType + Num + Clone + fmt::Debug + Send + Sync,
4776{
4777 /// Quantization metadata for this tensor, if set.
4778 pub fn quantization(&self) -> Option<&Quantization> {
4779 self.quantization.as_ref()
4780 }
4781
4782 /// Attach quantization metadata to this tensor. Validates against the
4783 /// tensor's shape — returns [`Error::QuantizationInvalid`] on any
4784 /// inconsistency (mismatched scale/zp lengths, out-of-range axis, etc.).
4785 pub fn set_quantization(&mut self, q: Quantization) -> Result<()> {
4786 q.validate(self.shape())?;
4787 self.quantization = Some(q);
4788 Ok(())
4789 }
4790
4791 /// Builder-style variant of [`Self::set_quantization`]. Consumes `self`
4792 /// and returns `Result<Self>` — on success yields the tensor with the
4793 /// attached quantization; on validation failure returns
4794 /// [`Error::QuantizationInvalid`] and drops `self` (the tensor is not
4795 /// returned in the error arm).
4796 pub fn with_quantization(mut self, q: Quantization) -> Result<Self> {
4797 self.set_quantization(q)?;
4798 Ok(self)
4799 }
4800
4801 /// Clear any quantization metadata on this tensor.
4802 pub fn clear_quantization(&mut self) {
4803 self.quantization = None;
4804 }
4805}
4806
4807#[cfg(feature = "static")]
4808fn map_strided_host_view<'a, T>(
4809 tensor: &Tensor<T>,
4810 stride: usize,
4811 access: CpuAccess,
4812) -> Result<crate::view::HostView<'a, T>>
4813where
4814 T: Num + Clone + fmt::Debug + Send + Sync + 'a,
4815{
4816 // Rows sit at `stride`-byte spacing. The row count is the first
4817 // shape dim for packed `[H, W, C]` and semi-planar `[H*k, W]`,
4818 // but planar `[C, H, W]` stacks C planes of H rows — its surface
4819 // row count is `C × H` (`shape[0]` alone would expose a 3-row
4820 // window and truncate the map; first hit by Android planar-F16
4821 // AHardwareBuffers, whose gralloc pads the pitch — macOS/Linux
4822 // planar pitches happen to be naturally aligned so no stride was
4823 // ever recorded there).
4824 let rows = strided_map_rows(tensor)?;
4825 let total_bytes = stride.checked_mul(rows).ok_or_else(|| {
4826 Error::InvalidOperation(format!(
4827 "Tensor::map: row_stride {stride} × rows {rows} overflows usize"
4828 ))
4829 })?;
4830 map_strided_storage(tensor, stride, rows, total_bytes, access)
4831}
4832
4833#[cfg(feature = "static")]
4834fn strided_map_rows<T>(tensor: &Tensor<T>) -> Result<usize>
4835where
4836 T: Num + Clone + fmt::Debug + Send + Sync,
4837{
4838 match tensor.format.map(|f| f.layout()) {
4839 Some(PixelLayout::Planar) => {
4840 let s = tensor.shape();
4841 if s.len() < 2 {
4842 return Err(Error::InvalidOperation(
4843 "Tensor::map: strided planar mapping requires [C, H, W] shape".into(),
4844 ));
4845 }
4846 s[0].checked_mul(s[1]).ok_or_else(|| {
4847 Error::InvalidOperation(format!(
4848 "Tensor::map: planar rows {} × {} overflows usize",
4849 s[0], s[1]
4850 ))
4851 })
4852 }
4853 _ => tensor.shape().first().copied().ok_or_else(|| {
4854 Error::InvalidOperation(
4855 "Tensor::map: strided mapping requires a non-empty shape".into(),
4856 )
4857 }),
4858 }
4859}
4860
4861#[cfg(feature = "static")]
4862fn map_strided_storage<'a, T>(
4863 tensor: &Tensor<T>,
4864 _stride: usize,
4865 _rows: usize,
4866 total_bytes: usize,
4867 access: CpuAccess,
4868) -> Result<crate::view::HostView<'a, T>>
4869where
4870 T: Num + Clone + fmt::Debug + Send + Sync + 'a,
4871{
4872 match &tensor.storage {
4873 #[cfg(target_os = "linux")]
4874 TensorStorage::Dma(dma) => {
4875 // `set_row_stride()` only validates `stride >= min_stride`,
4876 // not that `stride × rows` fits the DMA-BUF, so re-check
4877 // here — mapping past `buf_size` would SIGBUS on access.
4878 // `buf_size` is real kernel truth either way, not the
4879 // importer's say-so: for a self-allocated tensor it is
4880 // the allocation size; for an imported one it is
4881 // `fstat`-derived when the kernel reports a usable size
4882 // (`DmaTensor::from_fd`), or the declared logical size in
4883 // the rare fallback -- either way an out-of-range mmap
4884 // against the real dma-buf is still rejected by the
4885 // kernel, so this bound stays sound regardless.
4886 let available_bytes = dma.buf_size.saturating_sub(dma.mmap_offset);
4887 if total_bytes > available_bytes {
4888 return Err(Error::InvalidOperation(format!(
4889 "Tensor::map: strided mapping needs {total_bytes} bytes \
4890 but DMA buffer only has {available_bytes} available \
4891 (buf_size={}, mmap_offset={}, stride={_stride}, rows={_rows}); \
4892 the row_stride was likely set larger than the original allocation",
4893 dma.buf_size, dma.mmap_offset
4894 )));
4895 }
4896 dma.map_with_byte_size(total_bytes, access)
4897 }
4898 TensorStorage::Mem(mem) => {
4899 let capacity = tensor.storage.capacity_bytes();
4900 if total_bytes > capacity {
4901 return Err(Error::InsufficientCapacity {
4902 needed: total_bytes,
4903 capacity,
4904 });
4905 }
4906 mem.map_with_byte_size(total_bytes, access)
4907 }
4908 #[cfg(unix)]
4909 TensorStorage::Shm(shm) => {
4910 let capacity = tensor.storage.capacity_bytes();
4911 if total_bytes > capacity {
4912 return Err(Error::InsufficientCapacity {
4913 needed: total_bytes,
4914 capacity,
4915 });
4916 }
4917 shm.map_with_byte_size(total_bytes, access)
4918 }
4919 // macOS/iOS: `TensorStorage::Dma` is the IOSurface. The lock yields
4920 // the full surface base address, and the row pitch
4921 // (`IOSurfaceGetBytesPerRow`) is known from the API for both
4922 // self-allocated and imported surfaces — unlike a foreign
4923 // DMA-BUF — so a strided CPU view is sound and zero-copy.
4924 #[cfg(any(target_os = "macos", target_os = "ios"))]
4925 TensorStorage::Dma(io) => {
4926 // A sub-view's window is `buf_size − view_offset`; the strided
4927 // span must fit the window, not the whole surface.
4928 let available = io.buf_size.saturating_sub(io.view_offset);
4929 if total_bytes > available {
4930 return Err(Error::InsufficientCapacity {
4931 needed: total_bytes,
4932 capacity: available,
4933 });
4934 }
4935 io.map_with_byte_size(total_bytes, access)
4936 }
4937 // Android: `TensorStorage::Dma` is the AHardwareBuffer. The lock
4938 // yields the full buffer base address, and the row pitch is
4939 // known from the allocator-filled descriptor — so a strided CPU
4940 // view is sound and zero-copy, same as IOSurface.
4941 #[cfg(target_os = "android")]
4942 TensorStorage::Dma(ahb) => {
4943 // A sub-view's window is `buf_size − view_offset`; the strided
4944 // span must fit the window, not the whole buffer.
4945 let available = ahb.buf_size.saturating_sub(ahb.view_offset);
4946 if total_bytes > available {
4947 return Err(Error::InsufficientCapacity {
4948 needed: total_bytes,
4949 capacity: available,
4950 });
4951 }
4952 ahb.map_with_byte_size(total_bytes, access)
4953 }
4954 TensorStorage::Pbo(pbo) => {
4955 // PBO: the GPU-side allocation may have a padded row stride
4956 // (e.g. 64-byte aligned). Expose the full padded buffer so a
4957 // CPU producer (JPEG decoder) and a strided convert source
4958 // can iterate rows via `effective_row_stride()` without
4959 // running past the slice — the logical `pbo.map()` view would
4960 // stop after `shape.product()` and lose bytes past row 0.
4961 // A sub-view's window is `capacity − view_offset`.
4962 let available = pbo.capacity_bytes().saturating_sub(pbo.view_offset);
4963 if total_bytes > available {
4964 return Err(Error::InsufficientCapacity {
4965 needed: total_bytes,
4966 capacity: available,
4967 });
4968 }
4969 pbo.map_with_byte_size(total_bytes, access)
4970 }
4971 // Every `TensorStorage` variant this build can construct is
4972 // matched explicitly above (Mem/Shm/Dma/Pbo, each cfg-gated
4973 // to the platforms it exists on) -- this catch-all exists
4974 // only so the match stays exhaustive across every
4975 // OS/cfg combination without a matching arm on every one of
4976 // them, not because any variant is meant to fall through to
4977 // it. `#[allow(unreachable_patterns)]` because on any given
4978 // platform it genuinely is unreachable.
4979 #[allow(unreachable_patterns)]
4980 _ => Err(Error::InvalidOperation(
4981 "CPU mapping of strided tensors is supported only for HAL-allocated \
4982 Mem/Shm (any platform), DMA (Linux, self-allocated or imported), \
4983 IOSurface (macOS), and PBO"
4984 .into(),
4985 )),
4986 }
4987}
4988
4989#[cfg(feature = "static")]
4990impl<T> TensorTrait<T> for Tensor<T>
4991where
4992 T: Num + Clone + fmt::Debug + Send + Sync,
4993{
4994 fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
4995 where
4996 Self: Sized,
4997 {
4998 Self::new(shape, None, name)
4999 }
5000
5001 #[cfg(unix)]
5002 fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
5003 where
5004 Self: Sized,
5005 {
5006 #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
5007 let mut t = Self::wrap(TensorStorage::from_fd(fd, shape, name)?);
5008 // Best-effort CUDA external memory import for DMA-backed tensors.
5009 // RUNTIME-UNVALIDATED: see try_init_dma_cuda().
5010 #[cfg(target_os = "linux")]
5011 t.try_init_dma_cuda();
5012 Ok(t)
5013 }
5014
5015 #[cfg(unix)]
5016 fn clone_fd(&self) -> Result<std::os::fd::OwnedFd> {
5017 self.storage.clone_fd()
5018 }
5019
5020 fn memory(&self) -> TensorMemory {
5021 self.storage.memory()
5022 }
5023
5024 fn name(&self) -> String {
5025 self.storage.name()
5026 }
5027
5028 fn shape(&self) -> &[usize] {
5029 self.storage.shape()
5030 }
5031
5032 /// Bytes available in the underlying allocation, from this tensor's
5033 /// window start -- may exceed [`TensorTrait::size`] when the backing
5034 /// carries spare room beyond the current logical shape (a pitch-aligned
5035 /// image, or a pool tensor sized for a larger image than it holds
5036 /// today). See [`crate::protocol::TensorDesc::capacity`], the
5037 /// cross-package descriptor field this backs.
5038 fn capacity_bytes(&self) -> usize {
5039 self.storage.capacity_bytes()
5040 }
5041
5042 fn reshape(&mut self, shape: &[usize]) -> Result<()> {
5043 if self.chroma.is_some() {
5044 return Err(Error::InvalidOperation(
5045 "cannot reshape a multiplane tensor — decompose planes first".into(),
5046 ));
5047 }
5048 self.storage.reshape(shape)?;
5049 self.format = None;
5050 self.row_stride = None;
5051 self.plane_offset = None;
5052 match self.storage {
5053 TensorStorage::Mem(ref mut m) => m.set_offset(0),
5054 #[cfg(target_os = "linux")]
5055 TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
5056 _ => {}
5057 }
5058 Ok(())
5059 }
5060
5061 /// Set the logical shape to any shape whose bytes fit the allocation,
5062 /// without [`reshape`](TensorTrait::reshape)'s equal-count constraint.
5063 ///
5064 /// Overridden rather than inherited, which it was not for most of this
5065 /// crate's life. The trait's default body is `self.reshape(shape)`, so
5066 /// a caller reaching this through `Tensor<T>` silently got the strict
5067 /// rule under a name that promises the opposite -- on both backends,
5068 /// which is why G13 cannot see it.
5069 ///
5070 /// The capacity-aware implementations were never dead: every storage
5071 /// type has one, and [`Tensor::configure_image`] reaches them by
5072 /// calling `self.storage.set_logical_shape(..)` directly. That is the
5073 /// pool-reuse path -- a decode into an oversized reusable buffer
5074 /// reconfiguring it to a smaller image without reallocating -- and it
5075 /// worked precisely because it bypassed this method. This forwards the
5076 /// same way, so the two agree.
5077 fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
5078 self.storage.set_logical_shape(shape)
5079 }
5080
5081 fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
5082 where
5083 T: 'a,
5084 {
5085 let _span = tracing::trace_span!(
5086 "tensor.map",
5087 memory = ?self.storage.memory(),
5088 ?access,
5089 )
5090 .entered();
5091 if access == CpuAccess::None {
5092 return Err(Error::InvalidArgument(
5093 "map_with(CpuAccess::None) is not a mappable direction — use \
5094 map_read()/map_write()/map_mut()"
5095 .into(),
5096 ));
5097 }
5098 // Declared-vs-requested telemetry (all platforms): mapping beyond
5099 // the allocation-time declaration is best-effort — tolerated where
5100 // the backing is CPU-mappable regardless (Mem/Shm/dma-buf/
5101 // IOSurface), refused by the Android backend for CpuAccess::None
5102 // buffers — but always loud and counted, never silent.
5103 if !self.cpu_access.covers(access) {
5104 note_unplanned_cpu_access(
5105 self.buffer_identity().id(),
5106 &format!("{:?}", self.storage.memory()),
5107 "map access exceeds the declared CpuAccess",
5108 );
5109 }
5110 // CPU mapping of a strided tensor exposes the full padded buffer
5111 // (`row_stride × rows`) so callers can iterate rows via
5112 // `effective_row_stride()` without running past the slice. This is sound
5113 // only when the HAL owns and can size-check the allocation:
5114 //
5115 // * Self-allocated Mem / Shm tensors (any platform) — the backing
5116 // `Vec` / shm segment is sized by `capacity_bytes()`, checked here.
5117 // * Self-allocated DMA tensors (Linux) — pitch padding from
5118 // `image_with_stride()`; checked against the DMA-BUF `buf_size`.
5119 //
5120 // * Self-allocated PBO tensors (any platform with GL) — the GL buffer
5121 // is sized by `capacity_bytes()` and may carry 64-byte row padding;
5122 // the JPEG decoder mmaps it and convert() reads it, both iterating
5123 // by `row_stride`. Checked against the PBO capacity below.
5124 //
5125 // A strided **imported** DMA-BUF is accepted here too (Task 10b): the
5126 // `stride × rows <= buf_size` check just below is exactly what a
5127 // self-allocated Dma tensor relies on for its own soundness, and
5128 // `buf_size` for an imported fd is `fstat`-derived when the kernel
5129 // reports a usable size (`stat.st_size > 0` and at least the
5130 // declared shape's logical size; `DmaTensor::from_fd`) -- kernel
5131 // truth, not a caller's claim, in that case. The rare fallback (a
5132 // kernel that reports `st_size <= 0` for a DMA-BUF fd) uses the
5133 // declared logical size instead; that is not a hole here either, since
5134 // an out-of-range mmap against the real dma-buf is still rejected by
5135 // the kernel regardless of what this crate believed `buf_size` was.
5136 // So the check is equally load-bearing regardless of who allocated
5137 // the buffer. What
5138 // changed is only *which* callers can now reach this path: the
5139 // cross-package capsule protocol reconstructs an imported alias of a
5140 // HAL-owned Dma tensor and then calls `configure_image`, which
5141 // computes its stride with the exact same alignment rule a
5142 // self-allocated `Tensor::image()` used — so the value being
5143 // bounds-checked here is HAL-computed and format/width-derived, not
5144 // read from an external driver. Rejecting it unconditionally used to
5145 // make every foreign-imported GPU-backed destination fail any CPU
5146 // write path (the JPEG decoder's MCU writer among them) even when the
5147 // write was provably in-bounds. `set_row_stride()` was already
5148 // "trust the caller, bounds-check the map" for every *other* backing
5149 // (Mem/Shm/self-allocated Dma/PBO) — singling out imported Dma out
5150 // for a stricter standard was the inconsistency, not the trust model
5151 // itself. IOSurface keeps its own always-allowed strided path
5152 // unconditionally (see its arm below): a live `IOSurfaceRef` reports
5153 // its real `bytesPerRow` from the OS regardless of who allocated it,
5154 // so there was never anything to distrust there.
5155 if let Some(stride) = self.row_stride {
5156 return map_strided_host_view(self, stride, access);
5157 }
5158 // Offset tensors are supported for storages that apply the offset
5159 // inside their own `map()`: DMA (`DmaMap`/IOSurface adjust the mapped
5160 // base), Mem (`MemMap` adjusts the slice base), Shm (`ShmMap` adjusts
5161 // the slice base), and PBO (the staged copy starts at the offset). Every
5162 // self-allocated backing now carries a sub-region concept via `view`, so
5163 // a non-zero offset is honoured rather than rejected.
5164 if self.plane_offset.is_some_and(|o| o > 0) {
5165 let supported = matches!(self.storage, TensorStorage::Mem(_) | TensorStorage::Pbo(_));
5166 // macOS `Dma` is the IOSurface; Linux `Dma` is the DMA-BUF; Android
5167 // `Dma` is the AHardwareBuffer — all apply the offset in their map.
5168 // (`Dma` is the same variant name on each, hence one `cfg(any(...))`
5169 // arm rather than three.)
5170 #[cfg(any(
5171 target_os = "linux",
5172 target_os = "macos",
5173 target_os = "ios",
5174 target_os = "android"
5175 ))]
5176 let supported = supported || matches!(self.storage, TensorStorage::Dma(_));
5177 #[cfg(unix)]
5178 let supported = supported || matches!(self.storage, TensorStorage::Shm(_));
5179 if !supported {
5180 return Err(Error::InvalidOperation(
5181 "plane offset only supported for DMA, Mem, Shm, and PBO tensors".into(),
5182 ));
5183 }
5184 }
5185 self.storage.map_with(access)
5186 }
5187
5188 fn buffer_identity(&self) -> &BufferIdentity {
5189 self.storage.buffer_identity()
5190 }
5191}
5192
5193// ============================================================================
5194// Platform availability helpers
5195// ============================================================================
5196
5197/// Cached result of the Linux DMA-BUF availability probe.
5198#[cfg(target_os = "linux")]
5199static DMA_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5200/// Cached result of the macOS/iOS IOSurface availability probe.
5201#[cfg(any(target_os = "macos", target_os = "ios"))]
5202static IOSURFACE_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5203
5204/// Check if Linux DMA-BUF allocation is available on this system.
5205///
5206/// Linux-specific availability check (typically requires `/dev/dma_heap`
5207/// access — running as root or membership in a video/render group). For
5208/// portable code that wants "any zero-copy GPU buffer", use
5209/// [`is_gpu_buffer_available`] which also covers IOSurface on macOS.
5210///
5211/// This function caches its result after the first call.
5212#[cfg(target_os = "linux")]
5213pub fn is_dma_available() -> bool {
5214 *DMA_AVAILABLE
5215 .get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok())
5216}
5217
5218/// Always returns `false` on non-Linux platforms.
5219#[cfg(not(target_os = "linux"))]
5220pub fn is_dma_available() -> bool {
5221 false
5222}
5223
5224/// Check if macOS/iOS IOSurface allocation is available on this system.
5225///
5226/// IOSurface is part of the macOS/iOS OS and is essentially always present;
5227/// this probe catches degraded scenarios such as memory pressure or
5228/// sandboxed contexts where `IOSurfaceCreate` fails. The result is
5229/// cached after the first call.
5230///
5231/// Always returns `false` on non-Apple platforms.
5232#[cfg(any(target_os = "macos", target_os = "ios"))]
5233pub fn is_iosurface_available() -> bool {
5234 *IOSURFACE_AVAILABLE.get_or_init(|| {
5235 // Probe via the same Dma path — on macOS/iOS this routes through
5236 // IoSurfaceTensor::new.
5237 Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
5238 })
5239}
5240
5241#[cfg(not(any(target_os = "macos", target_os = "ios")))]
5242pub fn is_iosurface_available() -> bool {
5243 false
5244}
5245
5246/// Cached result of the Android AHardwareBuffer availability probe.
5247#[cfg(target_os = "android")]
5248static AHARDWAREBUFFER_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5249
5250/// Check if Android AHardwareBuffer allocation is available on this system.
5251///
5252/// AHardwareBuffer is part of the Android OS (public NDK ABI since API
5253/// 26) and is essentially always present; this probe catches degraded
5254/// scenarios such as memory pressure or gralloc failures. The result is
5255/// cached after the first call.
5256#[cfg(target_os = "android")]
5257pub fn is_ahardwarebuffer_available() -> bool {
5258 *AHARDWAREBUFFER_AVAILABLE.get_or_init(|| {
5259 // Probe via the same Dma path — on Android this routes through
5260 // AHardwareBufferTensor::new.
5261 Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
5262 })
5263}
5264
5265/// Always returns `false` on non-Android platforms.
5266#[cfg(not(target_os = "android"))]
5267pub fn is_ahardwarebuffer_available() -> bool {
5268 false
5269}
5270
5271/// Portable probe for the platform's native zero-copy GPU buffer
5272/// allocator (DMA-BUF on Linux, IOSurface on macOS/iOS, AHardwareBuffer on
5273/// Android). Returns `false` on
5274/// Windows and other platforms with no equivalent. Use this when writing
5275/// cross-platform code that cares whether the `Dma` tensor variant will
5276/// work, not which underlying mechanism is used.
5277pub fn is_gpu_buffer_available() -> bool {
5278 #[cfg(target_os = "linux")]
5279 {
5280 is_dma_available()
5281 }
5282 #[cfg(any(target_os = "macos", target_os = "ios"))]
5283 {
5284 is_iosurface_available()
5285 }
5286 #[cfg(target_os = "android")]
5287 {
5288 is_ahardwarebuffer_available()
5289 }
5290 #[cfg(not(any(
5291 target_os = "linux",
5292 target_os = "macos",
5293 target_os = "ios",
5294 target_os = "android"
5295 )))]
5296 {
5297 false
5298 }
5299}
5300
5301/// Check if POSIX shared memory allocation is available on this system.
5302///
5303/// Returns `true` on Unix systems (Linux, macOS, BSD) where POSIX shared memory
5304/// is supported. Always returns `false` on non-Unix platforms (Windows).
5305///
5306/// This function caches its result after the first call for efficiency.
5307#[cfg(unix)]
5308static SHM_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5309
5310/// Check if POSIX shared memory allocation is available on this system.
5311#[cfg(unix)]
5312pub fn is_shm_available() -> bool {
5313 *SHM_AVAILABLE.get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::Shm), None).is_ok())
5314}
5315
5316/// Check if POSIX shared memory allocation is available on this system.
5317///
5318/// Always returns `false` on non-Unix platforms since POSIX SHM is Unix-specific.
5319#[cfg(not(unix))]
5320pub fn is_shm_available() -> bool {
5321 false
5322}
5323
5324#[cfg(test)]
5325mod dtype_tests {
5326 use super::*;
5327
5328 #[test]
5329 fn dtype_size() {
5330 assert_eq!(DType::U8.size(), 1);
5331 assert_eq!(DType::I8.size(), 1);
5332 assert_eq!(DType::U16.size(), 2);
5333 assert_eq!(DType::I16.size(), 2);
5334 assert_eq!(DType::U32.size(), 4);
5335 assert_eq!(DType::I32.size(), 4);
5336 assert_eq!(DType::U64.size(), 8);
5337 assert_eq!(DType::I64.size(), 8);
5338 assert_eq!(DType::F16.size(), 2);
5339 assert_eq!(DType::F32.size(), 4);
5340 assert_eq!(DType::F64.size(), 8);
5341 }
5342
5343 #[test]
5344 fn dtype_name() {
5345 assert_eq!(DType::U8.name(), "u8");
5346 assert_eq!(DType::F16.name(), "f16");
5347 assert_eq!(DType::F32.name(), "f32");
5348 }
5349
5350 #[test]
5351 fn dtype_serde_roundtrip() {
5352 use serde_json;
5353 let dt = DType::F16;
5354 let json = serde_json::to_string(&dt).unwrap();
5355 let back: DType = serde_json::from_str(&json).unwrap();
5356 assert_eq!(dt, back);
5357 }
5358}
5359
5360#[cfg(test)]
5361mod image_tests {
5362 use super::*;
5363
5364 #[test]
5365 fn allocation_shape_per_layout() {
5366 assert_eq!(
5367 PixelFormat::Rgb.allocation_shape(640, 480),
5368 Some(vec![480, 640, 3])
5369 );
5370 assert_eq!(
5371 PixelFormat::Grey.allocation_shape(640, 480),
5372 Some(vec![480, 640, 1])
5373 );
5374 assert_eq!(
5375 PixelFormat::Nv12.allocation_shape(640, 480),
5376 Some(vec![720, 640])
5377 );
5378 // Odd height: combined-plane height is `481 + ceil(481/2)` = 481 + 241
5379 // = 722 rows. Logical height is recovered as `722 * 2 / 3` = 481.
5380 assert_eq!(
5381 PixelFormat::Nv12.allocation_shape(640, 481),
5382 Some(vec![722, 640])
5383 );
5384 // Odd width: shape carries the LOGICAL width (641).
5385 // The 64-aligned stride (>= 642) is stored separately on the Tensor.
5386 assert_eq!(
5387 PixelFormat::Nv12.allocation_shape(641, 480),
5388 Some(vec![720, 641])
5389 );
5390 // NV16 odd width: same — logical width in shape, stride separate.
5391 assert_eq!(
5392 PixelFormat::Nv16.allocation_shape(641, 480),
5393 Some(vec![960, 641])
5394 );
5395 assert_eq!(
5396 PixelFormat::PlanarRgb.allocation_shape(640, 480),
5397 Some(vec![3, 480, 640])
5398 );
5399 assert_eq!(
5400 PixelFormat::Nv16.allocation_shape(640, 480),
5401 Some(vec![960, 640])
5402 );
5403 }
5404
5405 #[test]
5406 fn raw_tensor_has_no_format() {
5407 let t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
5408 assert!(t.format().is_none());
5409 assert!(t.width().is_none());
5410 assert!(t.height().is_none());
5411 assert!(!t.is_multiplane());
5412 assert!(t.chroma().is_none());
5413 }
5414
5415 #[test]
5416 fn image_tensor_packed() {
5417 let t = Tensor::<u8>::image(
5418 640,
5419 480,
5420 PixelFormat::Rgba,
5421 None,
5422 crate::CpuAccess::ReadWrite,
5423 )
5424 .unwrap();
5425 assert_eq!(t.format(), Some(PixelFormat::Rgba));
5426 assert_eq!(t.width(), Some(640));
5427 assert_eq!(t.height(), Some(480));
5428 assert_eq!(t.shape(), &[480, 640, 4]);
5429 assert!(!t.is_multiplane());
5430 }
5431
5432 #[test]
5433 fn image_tensor_planar() {
5434 let t = Tensor::<u8>::image(
5435 640,
5436 480,
5437 PixelFormat::PlanarRgb,
5438 None,
5439 crate::CpuAccess::ReadWrite,
5440 )
5441 .unwrap();
5442 assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
5443 assert_eq!(t.width(), Some(640));
5444 assert_eq!(t.height(), Some(480));
5445 assert_eq!(t.shape(), &[3, 480, 640]);
5446 }
5447
5448 #[test]
5449 #[cfg(target_os = "macos")]
5450 fn image_tensor_dma_non_aligned_packed_width_pads_zero_copy() {
5451 // Declares this test as an fd-opener; see FD_LOCK.
5452 let _lock = crate::tests::fd_lock_shared();
5453 // RGBA u8 at width=4 → 4*4 = 16 bytes/row, not 64-byte aligned. RGBA has
5454 // a real IOSurface FourCC, so an explicit `Some(TensorMemory::DmaBuf)`
5455 // request now allocates a padded image IOSurface (64-aligned
5456 // `bytes_per_row`) and records the stride — a fully zero-copy buffer GL
5457 // can bind and the CPU can map via the strided path. (Previously this
5458 // failed loudly to avoid an 'L008' byte-bag downgrade; with a real
5459 // FourCC surface that concern no longer applies.)
5460 let t = Tensor::<u8>::image(
5461 4,
5462 4,
5463 PixelFormat::Rgba,
5464 Some(TensorMemory::DmaBuf),
5465 crate::CpuAccess::ReadWrite,
5466 )
5467 .expect("padded RGBA IOSurface should allocate");
5468 assert_eq!(t.format(), Some(PixelFormat::Rgba));
5469 assert_eq!(t.width(), Some(4));
5470 assert_eq!(t.height(), Some(4));
5471 let stride = t.effective_row_stride().expect("stride");
5472 assert_eq!(stride % 64, 0, "padded to 64-byte row alignment");
5473 assert!(stride >= 16);
5474 // A CPU map exposes the full padded surface for strided iteration.
5475 let m = t.map().expect("strided IOSurface map");
5476 assert_eq!(m.as_slice().len(), stride * 4);
5477 }
5478
5479 /// `per_pixel_bytes` that doesn't divide 64 evenly (e.g. RGB u8 with
5480 /// 3 B/pixel) makes a "Pad width to N" suggestion structurally
5481 /// impossible — there is no integer width whose `width * 3` is a
5482 /// multiple of 64. The error must still fire (no silent SHM
5483 /// fallback for explicit-DMA requests) and must spell out the
5484 /// alignment requirement; it just omits the misleading "pad to N"
5485 /// hint instead of printing a number whose row pitch still won't
5486 /// align.
5487 #[test]
5488 #[cfg(target_os = "macos")]
5489 fn image_tensor_dma_rejects_indivisible_pixel_pitch_without_pad_hint() {
5490 // Declares this test as an fd-opener; see FD_LOCK.
5491 let _lock = crate::tests::fd_lock_shared();
5492 // Width=10 RGB f32 → 120 B/row, not 64-byte aligned, and (Rgb,
5493 // F32) has no IOSurface mapping so the padded-stride tolerance
5494 // does not apply. The next 64-multiple (128 B) isn't an integer
5495 // multiple of 12 B/pixel, so the "pad width to N" hint can't
5496 // produce a valid number and must be omitted. (Rgb u8 used to be
5497 // this test's subject but now has a real RGBA8888 mapping with
5498 // padded-stride tolerance — see the test below.)
5499 let err = Tensor::<f32>::image(
5500 10,
5501 10,
5502 PixelFormat::Rgb,
5503 Some(TensorMemory::DmaBuf),
5504 crate::CpuAccess::ReadWrite,
5505 )
5506 .expect_err("RGB f32 with 12 B/pixel and non-aligned width must be rejected");
5507 match err {
5508 Error::InvalidArgument(msg) => {
5509 assert!(
5510 msg.contains("64-byte aligned"),
5511 "error must still name the alignment requirement: {msg}"
5512 );
5513 assert!(
5514 !msg.contains("Pad width"),
5515 "indivisible per-pixel pitch makes a width suggestion impossible; \
5516 hint must be omitted, got: {msg}"
5517 );
5518 assert!(
5519 msg.contains("memory=None") && msg.contains("TensorMemory::Mem"),
5520 "error must still list the always-applicable alternatives: {msg}"
5521 );
5522 }
5523 other => panic!("expected InvalidArgument, got {other:?}"),
5524 }
5525 }
5526
5527 #[test]
5528 #[cfg(target_os = "macos")]
5529 fn image_tensor_dma_packed_rgb_u8_contract() {
5530 // Declares this test as an fd-opener; see FD_LOCK.
5531 let _lock = crate::tests::fd_lock_shared();
5532 // Packed RGB u8 @Dma is a designed RGBA8888 mapping at
5533 // (W*3/4, H) — the INT8 NPU input layout, shared with Android.
5534 // width%4 != 0 cannot form whole texels → loud InvalidArgument…
5535 let err = Tensor::<u8>::image(
5536 10,
5537 10,
5538 PixelFormat::Rgb,
5539 Some(TensorMemory::DmaBuf),
5540 crate::CpuAccess::ReadWrite,
5541 )
5542 .expect_err("Rgb u8 width%4!=0 must be rejected");
5543 assert!(
5544 matches!(&err, Error::InvalidArgument(m) if m.contains("width%4==0")),
5545 "got {err:?}"
5546 );
5547 // …width%4 == 0 with a non-64-aligned pitch allocates PADDED
5548 // (36 B rows → 64 B surface pitch, recorded on the tensor)…
5549 let t = Tensor::<u8>::image(
5550 12,
5551 4,
5552 PixelFormat::Rgb,
5553 Some(TensorMemory::DmaBuf),
5554 crate::CpuAccess::ReadWrite,
5555 )
5556 .expect("width 12 Rgb u8 must allocate padded");
5557 assert_eq!(t.memory(), TensorMemory::DmaBuf);
5558 assert!(
5559 t.row_stride()
5560 .is_some_and(|s| s >= 64 && s.is_multiple_of(64)),
5561 "padded pitch must be recorded: {:?}",
5562 t.row_stride()
5563 );
5564 // …and the aligned model-input width stays flat (640*3 = 1920 is
5565 // 64-aligned → no recorded stride, the buffer IS [H, W, 3]).
5566 let t = Tensor::<u8>::image(
5567 640,
5568 8,
5569 PixelFormat::Rgb,
5570 Some(TensorMemory::DmaBuf),
5571 crate::CpuAccess::ReadWrite,
5572 )
5573 .expect("width 640 Rgb u8 must allocate flat");
5574 assert_eq!(t.row_stride(), None);
5575 // I8 shares the layout (INT8 shader bias, not a format change).
5576 let t = Tensor::<i8>::image(
5577 640,
5578 8,
5579 PixelFormat::Rgb,
5580 Some(TensorMemory::DmaBuf),
5581 crate::CpuAccess::ReadWrite,
5582 )
5583 .expect("Rgb i8 shares the RGBA8888 mapping");
5584 assert_eq!(t.memory(), TensorMemory::DmaBuf);
5585 }
5586
5587 #[test]
5588 #[cfg(target_os = "macos")]
5589 fn image_tensor_dma_planar_f16_alignment() {
5590 // Declares this test as an fd-opener; see FD_LOCK.
5591 let _lock = crate::tests::fd_lock_shared();
5592 // PlanarRgb F16 uses single-channel row pitch (width * 2 bytes).
5593 // Width=16 → 32 bytes/row (not aligned); width=32 → 64 bytes/row (aligned).
5594 let err = Tensor::<half::f16>::image(
5595 16,
5596 16,
5597 PixelFormat::PlanarRgb,
5598 Some(TensorMemory::DmaBuf),
5599 crate::CpuAccess::ReadWrite,
5600 )
5601 .expect_err("width=16 PlanarRgb F16 is 32-byte row, must reject");
5602 assert!(matches!(err, Error::InvalidArgument(_)), "got {err:?}");
5603 // 32 wide should work.
5604 let t = Tensor::<half::f16>::image(
5605 32,
5606 8,
5607 PixelFormat::PlanarRgb,
5608 Some(TensorMemory::DmaBuf),
5609 crate::CpuAccess::ReadWrite,
5610 )
5611 .expect("width=32 PlanarRgb F16 is 64-byte row, must succeed");
5612 assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
5613 }
5614
5615 #[test]
5616 fn image_tensor_semi_planar_contiguous() {
5617 let t = Tensor::<u8>::image(
5618 640,
5619 480,
5620 PixelFormat::Nv12,
5621 None,
5622 crate::CpuAccess::ReadWrite,
5623 )
5624 .unwrap();
5625 assert_eq!(t.format(), Some(PixelFormat::Nv12));
5626 assert_eq!(t.width(), Some(640));
5627 assert_eq!(t.height(), Some(480));
5628 // NV12: H*3/2 = 720
5629 assert_eq!(t.shape(), &[720, 640]);
5630 assert!(!t.is_multiplane());
5631 }
5632
5633 #[test]
5634 #[cfg(target_os = "linux")]
5635 fn image_tensor_with_stride_preserves_logical_width() {
5636 // Declares this test as an fd-opener; see FD_LOCK.
5637 let _lock = crate::tests::fd_lock_shared();
5638 // Skip if DMA not available (e.g. sandboxed CI lacking dma_heap access).
5639 if !is_dma_available() {
5640 eprintln!("SKIPPED: DMA heap not available");
5641 return;
5642 }
5643 // 3004×1688 RGBA8: natural pitch 12016, padded to 12032 (64-aligned).
5644 let stride = 12032;
5645 let t = Tensor::<u8>::image_with_stride(
5646 3004,
5647 1688,
5648 PixelFormat::Rgba,
5649 stride,
5650 Some(TensorMemory::DmaBuf),
5651 crate::CpuAccess::ReadWrite,
5652 )
5653 .unwrap();
5654 // Logical dimensions unchanged by padding — this is the contract.
5655 assert_eq!(t.width(), Some(3004));
5656 assert_eq!(t.height(), Some(1688));
5657 assert_eq!(t.shape(), &[1688, 3004, 4]);
5658 // Stride is carried separately and reports the padded pitch.
5659 assert_eq!(t.effective_row_stride(), Some(stride));
5660 // Buffer is sized to stride × height so the full padded layout fits,
5661 // and CPU map() works for self-allocated strided DMA tensors.
5662 use crate::TensorMapTrait;
5663 {
5664 let map = t.map().unwrap();
5665 assert!(
5666 map.as_slice().len() >= stride * 1688,
5667 "mapped buffer {} bytes < expected {}",
5668 map.as_slice().len(),
5669 stride * 1688
5670 );
5671 }
5672 // CPU write access works too — iterate rows using the padded stride,
5673 // touch only the active `width × bpp` region, verify it round-trips.
5674 {
5675 let mut map = t.map().unwrap();
5676 let slice = map.as_mut_slice();
5677 for y in 0..1688 {
5678 let row_start = y * stride;
5679 for x in 0..3004 {
5680 let p = row_start + x * 4;
5681 slice[p] = (y & 0xFF) as u8;
5682 slice[p + 1] = (x & 0xFF) as u8;
5683 slice[p + 2] = 0x42;
5684 slice[p + 3] = 0xFF;
5685 }
5686 }
5687 }
5688 {
5689 let map = t.map().unwrap();
5690 let slice = map.as_slice();
5691 // Sample a few pixels to confirm the round-trip.
5692 assert_eq!(slice[0], 0x00);
5693 assert_eq!(slice[1], 0x00);
5694 assert_eq!(slice[2], 0x42);
5695 assert_eq!(slice[3], 0xFF);
5696 let mid = 100 * stride + 50 * 4;
5697 assert_eq!(slice[mid], 100);
5698 assert_eq!(slice[mid + 1], 50);
5699 assert_eq!(slice[mid + 2], 0x42);
5700 }
5701 }
5702
5703 #[test]
5704 #[cfg(target_os = "linux")]
5705 fn image_tensor_foreign_strided_map_is_bounds_checked_not_refused() {
5706 // An imported (foreign) DMA-BUF with a row stride used to be refused
5707 // outright. That changed deliberately: the cross-package capsule
5708 // protocol reconstructs an imported alias of a HAL-owned Dma tensor,
5709 // and refusing every strided CPU map broke the flagship
5710 // create_image() -> decode_file_into() -> convert() pipeline on Linux.
5711 //
5712 // What replaced the refusal is a bounds check against `buf_size`,
5713 // which for an imported fd is `fstat`-derived kernel truth rather
5714 // than the caller's claim. So the property worth testing is no longer
5715 // "foreign strided maps fail" but "they succeed exactly when they
5716 // fit" -- which is what actually keeps the mapping sound.
5717 //
5718 // This test previously asserted the old refusal and went red on every
5719 // board with a dma-heap when the behaviour changed, unnoticed because
5720 // it needs both Linux and a dma-heap: it skips on macOS and on Orin
5721 // (nvmap, no CONFIG_DMABUF_HEAPS).
5722 if !is_dma_available() {
5723 eprintln!("SKIPPED: DMA heap not available");
5724 return;
5725 }
5726 let backing =
5727 Tensor::<u8>::new(&[240 * 320 * 4], Some(TensorMemory::DmaBuf), None).unwrap();
5728
5729 // In bounds: the natural stride for the declared image fits the
5730 // allocation, so the map must succeed.
5731 {
5732 let fd = backing.clone_fd().unwrap();
5733 let shape = [240usize, 320, 4];
5734 let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
5735 let mut t = Tensor::<u8>::wrap(storage);
5736 t.set_format(PixelFormat::Bgra).unwrap();
5737 t.set_row_stride(320 * 4).unwrap();
5738 assert!(
5739 t.map().is_ok(),
5740 "an in-bounds strided map of an imported DMA-BUF must be \
5741 accepted -- refusing it is what broke decode-into-GPU-tensor"
5742 );
5743 }
5744
5745 // Out of bounds: a stride the allocation cannot back must still be
5746 // refused, and the error must say so. This is the half that carries
5747 // the soundness.
5748 {
5749 let fd = backing.clone_fd().unwrap();
5750 let shape = [240usize, 320, 4];
5751 let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
5752 let mut t = Tensor::<u8>::wrap(storage);
5753 t.set_format(PixelFormat::Bgra).unwrap();
5754 // Double the pitch: 240 rows x 2560 bytes is twice the allocation.
5755 t.set_row_stride(320 * 4 * 2).unwrap();
5756 let err = t.map();
5757 assert!(
5758 matches!(err, Err(Error::InvalidOperation(_))),
5759 "a strided map larger than the DMA allocation must be refused"
5760 );
5761 }
5762 }
5763
5764 #[test]
5765 #[cfg(target_os = "linux")]
5766 fn image_tensor_with_stride_map_rejects_tampered_stride() {
5767 // Declares this test as an fd-opener; see FD_LOCK.
5768 let _lock = crate::tests::fd_lock_shared();
5769 // Round-3 PR feedback (C1): `set_row_stride` is public and only
5770 // validates `stride >= min_stride`, not that the new stride × height
5771 // fits the underlying buffer. A caller that tampers with the stride
5772 // after allocation must not be able to coerce `Tensor::map()` into
5773 // returning a slice larger than the backing mmap (that would be UB
5774 // in `DmaMap::as_slice`).
5775 if !is_dma_available() {
5776 eprintln!("SKIPPED: DMA heap not available");
5777 return;
5778 }
5779 // Allocate a 640×480 RGBA8 padded canvas (stride = 3072 = 768 px).
5780 // Backing buffer is 3072 × 480 = 1,474,560 bytes.
5781 let mut t = Tensor::<u8>::image_with_stride(
5782 640,
5783 480,
5784 PixelFormat::Rgba,
5785 3072,
5786 Some(TensorMemory::DmaBuf),
5787 crate::CpuAccess::ReadWrite,
5788 )
5789 .unwrap();
5790 // Tamper: push the stride up to 4 × the original. This is >=
5791 // min_stride (2560), so `set_row_stride` accepts it.
5792 t.set_row_stride(12288).unwrap();
5793 // Map must now refuse — 12288 × 480 = 5,898,240 > 1,474,560.
5794 let err = t.map();
5795 assert!(
5796 matches!(err, Err(Error::InvalidOperation(_))),
5797 "map() with oversized stride must return InvalidOperation"
5798 );
5799 }
5800
5801 #[test]
5802 fn dma_tensor_new_with_byte_size_rejects_shape_overflow() {
5803 // Round-3 PR feedback (C3): shape.product() * sizeof(T) must use
5804 // checked arithmetic so a pathological shape can't wrap usize and
5805 // make the byte_size-vs-logical-size comparison incorrect.
5806 //
5807 // This test only exercises the overflow rejection path, which is
5808 // pure-Rust and doesn't touch dma_heap — safe to run on any target.
5809 #[cfg(target_os = "linux")]
5810 {
5811 let err = crate::dma::DmaTensor::<u64>::new_with_byte_size(
5812 &[usize::MAX, 2, 2],
5813 usize::MAX,
5814 None,
5815 );
5816 assert!(
5817 matches!(err, Err(Error::InvalidArgument(_))),
5818 "new_with_byte_size must detect shape.product() overflow"
5819 );
5820 }
5821 }
5822
5823 #[test]
5824 #[cfg(target_os = "linux")]
5825 fn image_tensor_with_stride_rejects_too_small_stride() {
5826 // Declares this test as an fd-opener; see FD_LOCK.
5827 let _lock = crate::tests::fd_lock_shared();
5828 // 640×480 RGBA8 natural pitch = 2560, request 2400 → should error.
5829 let err = Tensor::<u8>::image_with_stride(
5830 640,
5831 480,
5832 PixelFormat::Rgba,
5833 2400,
5834 Some(TensorMemory::DmaBuf),
5835 crate::CpuAccess::ReadWrite,
5836 );
5837 assert!(matches!(err, Err(Error::InvalidArgument(_))));
5838 }
5839
5840 #[test]
5841 #[cfg(target_os = "linux")]
5842 fn image_tensor_with_stride_rejects_non_packed() {
5843 // Declares this test as an fd-opener; see FD_LOCK.
5844 let _lock = crate::tests::fd_lock_shared();
5845 // NV12 is SemiPlanar → not supported. (Linux-only because
5846 // `TensorMemory::DmaBuf` itself is a Linux-only enum variant.)
5847 let err = Tensor::<u8>::image_with_stride(
5848 640,
5849 480,
5850 PixelFormat::Nv12,
5851 640,
5852 Some(TensorMemory::DmaBuf),
5853 crate::CpuAccess::ReadWrite,
5854 );
5855 assert!(matches!(err, Err(Error::NotImplemented(_))));
5856 }
5857
5858 #[test]
5859 fn set_format_valid() {
5860 let mut t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
5861 assert!(t.format().is_none());
5862 t.set_format(PixelFormat::Rgb).unwrap();
5863 assert_eq!(t.format(), Some(PixelFormat::Rgb));
5864 assert_eq!(t.width(), Some(640));
5865 assert_eq!(t.height(), Some(480));
5866 }
5867
5868 #[test]
5869 fn set_format_invalid_shape() {
5870 let mut t = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
5871 // RGB expects 3 channels, not 4
5872 let err = t.set_format(PixelFormat::Rgb);
5873 assert!(err.is_err());
5874 // Original tensor is unmodified
5875 assert!(t.format().is_none());
5876 }
5877
5878 #[test]
5879 fn reshape_clears_format() {
5880 let mut t = Tensor::<u8>::image(
5881 640,
5882 480,
5883 PixelFormat::Rgba,
5884 None,
5885 crate::CpuAccess::ReadWrite,
5886 )
5887 .unwrap();
5888 assert_eq!(t.format(), Some(PixelFormat::Rgba));
5889 // Reshape to flat — format cleared
5890 t.reshape(&[480 * 640 * 4]).unwrap();
5891 assert!(t.format().is_none());
5892 }
5893
5894 #[test]
5895 fn from_planes_nv12() {
5896 let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
5897 let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
5898 let img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
5899 assert_eq!(img.format(), Some(PixelFormat::Nv12));
5900 assert!(img.is_multiplane());
5901 assert!(img.chroma().is_some());
5902 assert_eq!(img.width(), Some(640));
5903 assert_eq!(img.height(), Some(480));
5904 }
5905
5906 #[test]
5907 fn from_planes_rejects_non_semiplanar() {
5908 let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
5909 let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
5910 let err = Tensor::from_planes(y, uv, PixelFormat::Rgb);
5911 assert!(err.is_err());
5912 }
5913
5914 #[test]
5915 fn reshape_multiplane_errors() {
5916 let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
5917 let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
5918 let mut img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
5919 let err = img.reshape(&[480 * 640 + 240 * 640]);
5920 assert!(err.is_err());
5921 }
5922}
5923
5924#[cfg(test)]
5925mod compression_tests {
5926 use super::*;
5927
5928 #[test]
5929 fn desc_builder_roundtrips() {
5930 let desc = ImageDesc::new(640, 480, PixelFormat::Rgba, DType::U8)
5931 .with_memory(Some(TensorMemory::Mem))
5932 .with_access(CpuAccess::Read)
5933 .with_compression(Compression::Any);
5934 assert_eq!(desc.width(), 640);
5935 assert_eq!(desc.height(), 480);
5936 assert_eq!(desc.format(), PixelFormat::Rgba);
5937 assert_eq!(desc.dtype(), DType::U8);
5938 assert_eq!(desc.memory(), Some(TensorMemory::Mem));
5939 assert_eq!(desc.access(), CpuAccess::Read);
5940 assert_eq!(desc.compression(), Some(Compression::Any));
5941
5942 // Defaults: auto memory, hardware-only, no request.
5943 let plain = ImageDesc::new(2, 2, PixelFormat::Grey, DType::U8);
5944 assert_eq!(plain.memory(), None);
5945 assert_eq!(plain.access(), CpuAccess::None);
5946 assert_eq!(plain.compression(), None);
5947 }
5948
5949 #[test]
5950 fn desc_dtype_must_match_element_type() {
5951 let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::F32);
5952 match Tensor::<u8>::image_desc(&desc) {
5953 Err(Error::InvalidArgument(msg)) => assert!(msg.contains("dtype")),
5954 other => panic!("expected InvalidArgument, got {other:?}"),
5955 }
5956 }
5957
5958 #[test]
5959 fn compression_with_cpu_access_is_invalid() {
5960 let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
5961 .with_access(CpuAccess::ReadWrite)
5962 .with_compression(Compression::Any);
5963 match Tensor::<u8>::image_desc(&desc) {
5964 Err(Error::InvalidArgument(msg)) => assert!(msg.contains("CpuAccess::None")),
5965 other => panic!("expected InvalidArgument, got {other:?}"),
5966 }
5967 }
5968
5969 #[cfg(not(target_os = "android"))]
5970 #[test]
5971 fn scheme_request_off_android_is_not_implemented() {
5972 let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
5973 .with_compression(Compression::Scheme(CompressionScheme::Ubwc));
5974 match Tensor::<u8>::image_desc(&desc) {
5975 Err(Error::NotImplemented(msg)) => assert!(msg.contains("Ubwc")),
5976 other => panic!("expected NotImplemented, got {other:?}"),
5977 }
5978 }
5979
5980 #[cfg(not(target_os = "android"))]
5981 #[test]
5982 fn any_request_off_android_resolves_linear_and_counts() {
5983 let before = compression_fallback_count();
5984 let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
5985 .with_memory(Some(TensorMemory::Mem))
5986 .with_compression(Compression::Any);
5987 let t = Tensor::<u8>::image_desc(&desc).unwrap();
5988 assert_eq!(t.compression(), None);
5989 assert!(compression_fallback_count() > before);
5990 }
5991
5992 #[test]
5993 fn desc_without_request_matches_classic_constructor() {
5994 let desc = ImageDesc::new(32, 32, PixelFormat::Rgba, DType::U8)
5995 .with_memory(Some(TensorMemory::Mem))
5996 .with_access(CpuAccess::ReadWrite);
5997 let t = Tensor::<u8>::image_desc(&desc).unwrap();
5998 assert_eq!(t.compression(), None);
5999 assert_eq!(t.cpu_access(), CpuAccess::ReadWrite);
6000 assert_eq!(t.width(), Some(32));
6001 // Mappable exactly like the classic constructor's result.
6002 let m = t.map_read().unwrap();
6003 assert_eq!(m.as_slice().len(), 32 * 32 * 4);
6004 }
6005
6006 #[test]
6007 fn configure_image_preserves_compression_and_views_inherit() {
6008 // No host platform records a scheme, so emulate the recording to
6009 // pin the preserve/inherit semantics (the physical layout does
6010 // not change when the logical image is reconfigured).
6011 let mut t = Tensor::<u8>::image(
6012 64,
6013 64,
6014 PixelFormat::Rgba,
6015 Some(TensorMemory::Mem),
6016 CpuAccess::None,
6017 )
6018 .unwrap();
6019 t.compression = Some(CompressionScheme::Ubwc);
6020 t.configure_image(32, 32, PixelFormat::Rgba).unwrap();
6021 assert_eq!(t.compression(), Some(CompressionScheme::Ubwc));
6022 let view = t.subview(0, &[16, 32, 4]).unwrap();
6023 assert_eq!(view.compression(), Some(CompressionScheme::Ubwc));
6024 }
6025
6026 #[test]
6027 fn tensor_dyn_dispatches_desc_and_compression() {
6028 let desc = ImageDesc::new(16, 16, PixelFormat::Rgba, DType::U8)
6029 .with_memory(Some(TensorMemory::Mem))
6030 .with_access(CpuAccess::ReadWrite);
6031 let t = TensorDyn::image_desc(&desc).unwrap();
6032 assert_eq!(t.compression(), None);
6033 assert!(matches!(t, TensorDyn::U8(_)));
6034 }
6035}
6036
6037#[cfg(test)]
6038mod cpu_access_tests {
6039 use super::*;
6040
6041 #[test]
6042 fn covers_matrix() {
6043 use CpuAccess::*;
6044 // Every declaration covers a narrower or equal request…
6045 for a in [None, Read, Write, ReadWrite] {
6046 assert!(a.covers(None), "{a:?} must cover None");
6047 assert!(ReadWrite.covers(a), "ReadWrite must cover {a:?}");
6048 }
6049 assert!(Read.covers(Read));
6050 assert!(Write.covers(Write));
6051 // …and never a wider one.
6052 assert!(!None.covers(Read));
6053 assert!(!None.covers(Write));
6054 assert!(!Read.covers(Write));
6055 assert!(!Read.covers(ReadWrite));
6056 assert!(!Write.covers(Read));
6057 assert!(!Write.covers(ReadWrite));
6058 }
6059
6060 #[test]
6061 fn map_with_none_is_invalid() {
6062 let t = Tensor::<u8>::new(&[16], Some(TensorMemory::Mem), None).unwrap();
6063 match t.map_with(CpuAccess::None) {
6064 Err(Error::InvalidArgument(_)) => {}
6065 Err(other) => panic!("expected InvalidArgument, got {other:?}"),
6066 Ok(_) => panic!("map_with(CpuAccess::None) must not succeed"),
6067 }
6068 }
6069
6070 #[test]
6071 fn read_map_rejects_mutation_uniformly() {
6072 // Mem backend: map_read yields a working read view whose mutable
6073 // accessor panics (the uniform cross-backend contract).
6074 let t = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
6075 t.map_mut().unwrap().as_mut_slice().copy_from_slice(&[7; 8]);
6076 let ro = t.map_read().unwrap();
6077 assert_eq!(ro.as_slice(), &[7; 8]);
6078 drop(ro);
6079 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6080 let mut ro = t.map_read().unwrap();
6081 let _ = ro.as_mut_slice();
6082 }));
6083 assert!(result.is_err(), "as_mut_slice through map_read must panic");
6084 }
6085
6086 #[test]
6087 fn write_and_rw_maps_stay_mutable() {
6088 let t = Tensor::<u8>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
6089 t.map_write()
6090 .unwrap()
6091 .as_mut_slice()
6092 .copy_from_slice(&[1; 4]);
6093 t.map().unwrap().as_mut_slice().copy_from_slice(&[2; 4]);
6094 assert_eq!(t.map_read().unwrap().as_slice(), &[2; 4]);
6095 }
6096
6097 /// The read-only IOSurface lock path: a `map_read` must observe data
6098 /// written through a prior read-write lock, and its unlock (which
6099 /// skips the cache flush) must not disturb subsequent reads.
6100 #[test]
6101 #[cfg(target_os = "macos")]
6102 fn iosurface_read_only_lock_roundtrip() {
6103 // Declares this test as an fd-opener; see FD_LOCK.
6104 let _lock = crate::tests::fd_lock_shared();
6105 let Ok(t) = Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None) else {
6106 eprintln!("SKIPPED: IOSurface unavailable");
6107 return;
6108 };
6109 {
6110 let mut m = t.map_mut().unwrap();
6111 for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
6112 *b = (i * 3) as u8;
6113 }
6114 }
6115 for _ in 0..2 {
6116 let ro = t.map_read().unwrap();
6117 for (i, b) in ro.as_slice().iter().enumerate() {
6118 assert_eq!(*b, (i * 3) as u8, "byte {i} through read-only lock");
6119 }
6120 }
6121 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6122 let mut ro = t.map_read().unwrap();
6123 let _ = ro.as_mut_slice();
6124 }));
6125 assert!(result.is_err(), "IOSurface read map must reject mutation");
6126 }
6127}
6128
6129#[cfg(test)]
6130mod tests {
6131 #[cfg(target_os = "linux")]
6132 use nix::unistd::{access, AccessFlags};
6133 #[cfg(target_os = "linux")]
6134 use std::io::Write as _;
6135 use std::sync::{PoisonError, RwLock};
6136
6137 use super::*;
6138
6139 #[ctor::ctor(unsafe)]
6140 fn init() {
6141 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
6142 }
6143
6144 /// Macro to get the current function name for logging in tests.
6145 #[cfg(target_os = "linux")]
6146 macro_rules! function {
6147 () => {{
6148 fn f() {}
6149 fn type_name_of<T>(_: T) -> &'static str {
6150 std::any::type_name::<T>()
6151 }
6152 let name = type_name_of(f);
6153
6154 // Find and cut the rest of the path
6155 match &name[..name.len() - 3].rfind(':') {
6156 Some(pos) => &name[pos + 1..name.len() - 3],
6157 None => &name[..name.len() - 3],
6158 }
6159 }};
6160 }
6161
6162 #[test]
6163 #[cfg(target_os = "linux")]
6164 fn test_tensor() {
6165 let _lock = crate::tests::fd_lock_shared();
6166 let shape = vec![1];
6167 let tensor = DmaTensor::<f32>::new(&shape, Some("dma_tensor"));
6168 let dma_enabled = tensor.is_ok();
6169
6170 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
6171 // Auto-select priority is Dma > Mem; Shm is never auto-selected.
6172 match dma_enabled {
6173 true => assert_eq!(tensor.memory(), TensorMemory::DmaBuf),
6174 false => assert_eq!(tensor.memory(), TensorMemory::Mem),
6175 }
6176 }
6177
6178 #[test]
6179 #[cfg(any(target_os = "macos", target_os = "ios"))]
6180 fn test_tensor() {
6181 let shape = vec![1];
6182 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
6183 // macOS/iOS auto-fallback chain: IOSurface (Dma) → Mem. Healthy systems
6184 // return Dma; Mem only appears under memory pressure or sandboxed
6185 // contexts where IOSurfaceCreate fails. Shm is never auto-selected.
6186 let m = tensor.memory();
6187 assert!(
6188 matches!(m, TensorMemory::DmaBuf | TensorMemory::Mem),
6189 "Unexpected auto-fallback result on macOS/iOS: {m:?}"
6190 );
6191 }
6192
6193 #[test]
6194 #[cfg(all(
6195 unix,
6196 not(any(target_os = "linux", target_os = "macos", target_os = "ios"))
6197 ))]
6198 fn test_tensor() {
6199 let shape = vec![1];
6200 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
6201 // Other Unix (BSD): no DMA, so auto-selection is Mem (Shm is
6202 // explicit-only, never auto-selected).
6203 assert_eq!(tensor.memory(), TensorMemory::Mem);
6204 }
6205
6206 #[test]
6207 #[cfg(not(unix))]
6208 fn test_tensor() {
6209 let shape = vec![1];
6210 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
6211 assert_eq!(tensor.memory(), TensorMemory::Mem);
6212 }
6213
6214 #[test]
6215 #[cfg(target_os = "linux")]
6216 fn test_dma_tensor() {
6217 let _lock = crate::tests::fd_lock_shared();
6218 match access(
6219 "/dev/dma_heap/linux,cma",
6220 AccessFlags::R_OK | AccessFlags::W_OK,
6221 ) {
6222 Ok(_) => println!("/dev/dma_heap/linux,cma is available"),
6223 Err(_) => match access(
6224 "/dev/dma_heap/system",
6225 AccessFlags::R_OK | AccessFlags::W_OK,
6226 ) {
6227 Ok(_) => println!("/dev/dma_heap/system is available"),
6228 Err(e) => {
6229 writeln!(
6230 &mut std::io::stdout(),
6231 "[WARNING] DMA Heap is unavailable: {e}"
6232 )
6233 .unwrap();
6234 return;
6235 }
6236 },
6237 }
6238
6239 let shape = vec![2, 3, 4];
6240 let tensor =
6241 DmaTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
6242
6243 const DUMMY_VALUE: f32 = 12.34;
6244
6245 assert_eq!(tensor.memory(), TensorMemory::DmaBuf);
6246 assert_eq!(tensor.name(), "test_tensor");
6247 assert_eq!(tensor.shape(), &shape);
6248 assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
6249 assert_eq!(tensor.len(), 2 * 3 * 4);
6250
6251 {
6252 let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
6253 tensor_map.fill(42.0);
6254 assert!(tensor_map.iter().all(|&x| x == 42.0));
6255 }
6256
6257 {
6258 let shared = Tensor::<f32>::from_fd(
6259 tensor
6260 .clone_fd()
6261 .expect("Failed to duplicate tensor file descriptor"),
6262 &shape,
6263 Some("test_tensor_shared"),
6264 )
6265 .expect("Failed to create tensor from fd");
6266
6267 assert_eq!(shared.memory(), TensorMemory::DmaBuf);
6268 assert_eq!(shared.name(), "test_tensor_shared");
6269 assert_eq!(shared.shape(), &shape);
6270
6271 let mut tensor_map = shared.map().expect("Failed to map DMA memory from fd");
6272 tensor_map.fill(DUMMY_VALUE);
6273 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
6274 }
6275
6276 {
6277 let tensor_map = tensor.map().expect("Failed to map DMA memory");
6278 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
6279 }
6280
6281 let mut tensor = DmaTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
6282 assert_eq!(tensor.shape(), &shape);
6283 let new_shape = vec![3, 4, 4];
6284 assert!(
6285 tensor.reshape(&new_shape).is_err(),
6286 "Reshape should fail due to size mismatch"
6287 );
6288 assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
6289
6290 let new_shape = vec![2, 3, 4];
6291 tensor.reshape(&new_shape).expect("Reshape should succeed");
6292 assert_eq!(
6293 tensor.shape(),
6294 &new_shape,
6295 "Shape should be updated after successful reshape"
6296 );
6297
6298 {
6299 let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
6300 tensor_map.fill(1);
6301 assert!(tensor_map.iter().all(|&x| x == 1));
6302 }
6303
6304 {
6305 let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
6306 tensor_map[2] = 42;
6307 assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
6308 assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
6309 }
6310 }
6311
6312 #[test]
6313 #[cfg(unix)]
6314 fn test_shm_tensor() {
6315 let _lock = crate::tests::fd_lock_shared();
6316 if !is_shm_available() {
6317 log::warn!("SKIPPED: test_shm_tensor - SHM allocation unavailable on this platform");
6318 return;
6319 }
6320 let shape = vec![2, 3, 4];
6321 let tensor =
6322 ShmTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
6323 assert_eq!(tensor.shape(), &shape);
6324 assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
6325 assert_eq!(tensor.name(), "test_tensor");
6326
6327 const DUMMY_VALUE: f32 = 12.34;
6328 {
6329 let mut tensor_map = tensor.map().expect("Failed to map shared memory");
6330 tensor_map.fill(42.0);
6331 assert!(tensor_map.iter().all(|&x| x == 42.0));
6332 }
6333
6334 {
6335 let shared = Tensor::<f32>::from_fd(
6336 tensor
6337 .clone_fd()
6338 .expect("Failed to duplicate tensor file descriptor"),
6339 &shape,
6340 Some("test_tensor_shared"),
6341 )
6342 .expect("Failed to create tensor from fd");
6343
6344 assert_eq!(shared.memory(), TensorMemory::Shm);
6345 assert_eq!(shared.name(), "test_tensor_shared");
6346 assert_eq!(shared.shape(), &shape);
6347
6348 let mut tensor_map = shared.map().expect("Failed to map shared memory from fd");
6349 tensor_map.fill(DUMMY_VALUE);
6350 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
6351 }
6352
6353 {
6354 let tensor_map = tensor.map().expect("Failed to map shared memory");
6355 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
6356 }
6357
6358 let mut tensor = ShmTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
6359 assert_eq!(tensor.shape(), &shape);
6360 let new_shape = vec![3, 4, 4];
6361 assert!(
6362 tensor.reshape(&new_shape).is_err(),
6363 "Reshape should fail due to size mismatch"
6364 );
6365 assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
6366
6367 let new_shape = vec![2, 3, 4];
6368 tensor.reshape(&new_shape).expect("Reshape should succeed");
6369 assert_eq!(
6370 tensor.shape(),
6371 &new_shape,
6372 "Shape should be updated after successful reshape"
6373 );
6374
6375 {
6376 let mut tensor_map = tensor.map().expect("Failed to map shared memory");
6377 tensor_map.fill(1);
6378 assert!(tensor_map.iter().all(|&x| x == 1));
6379 }
6380
6381 {
6382 let mut tensor_map = tensor.map().expect("Failed to map shared memory");
6383 tensor_map[2] = 42;
6384 assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
6385 assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
6386 }
6387 }
6388
6389 #[test]
6390 fn mem_subview_partitions_parent_buffer() {
6391 // One heap [2,4] u8 parent (8 bytes). Two [1,4] sub-views at byte
6392 // offsets 0 and 4 must share the parent allocation (zero-copy) and be
6393 // independently writable: view 0 owns bytes [0,4), view 1 owns [4,8).
6394 // Today this is impossible — heap offset is rejected and there is no
6395 // shared sub-view constructor.
6396 let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Mem), None).unwrap();
6397 let view0 = parent.subview(0, &[1, 4]).expect("subview at offset 0");
6398 let view1 = parent.subview(4, &[1, 4]).expect("subview at offset 4");
6399
6400 view1
6401 .map()
6402 .unwrap()
6403 .as_mut_slice()
6404 .copy_from_slice(&[10, 20, 30, 40]);
6405 view0
6406 .map()
6407 .unwrap()
6408 .as_mut_slice()
6409 .copy_from_slice(&[1, 2, 3, 4]);
6410
6411 // Each view sees only its own window.
6412 assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
6413 assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
6414 // The parent buffer is correctly partitioned (shared, zero-copy).
6415 assert_eq!(
6416 parent.map().unwrap().as_slice(),
6417 &[1, 2, 3, 4, 10, 20, 30, 40]
6418 );
6419 }
6420
6421 #[test]
6422 fn batch_partitions_leading_dim() {
6423 // Raw [4,2,2,3] u8 batched tensor: 4 elements of 12 bytes each. batch(n)
6424 // yields element n at offset n*12, sharing the parent buffer (zero-copy).
6425 let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
6426 for i in 0..4u8 {
6427 let e = parent.batch(i as usize).expect("batch element");
6428 assert_eq!(e.shape(), &[2, 2, 3]);
6429 // A batch element shares the parent's BufferIdentity.
6430 assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
6431 for b in e.map().unwrap().as_mut_slice() {
6432 *b = i + 1;
6433 }
6434 }
6435 // Each element occupies its own 12-byte band of the parent.
6436 let whole = parent.map().unwrap();
6437 let s = whole.as_slice();
6438 for i in 0..4usize {
6439 assert!(
6440 s[i * 12..(i + 1) * 12].iter().all(|&b| b == (i as u8 + 1)),
6441 "band {i} not partitioned: {:?}",
6442 &s[i * 12..(i + 1) * 12]
6443 );
6444 }
6445 }
6446
6447 #[test]
6448 fn view_origin_snapshots_parent_and_composes() {
6449 // view() on a whole image snapshots the parent dims + the view's origin.
6450 let parent = Tensor::<u8>::image(
6451 100,
6452 80,
6453 PixelFormat::Rgba,
6454 Some(TensorMemory::Mem),
6455 crate::CpuAccess::ReadWrite,
6456 )
6457 .unwrap();
6458 assert_eq!(
6459 parent.view_origin(),
6460 None,
6461 "whole tensor has no view_origin"
6462 );
6463 let v = parent.view(Region::new(10, 20, 30, 40)).unwrap();
6464 assert_eq!(
6465 v.view_origin(),
6466 Some(ViewOrigin {
6467 parent_width: 100,
6468 parent_height: 80,
6469 parent_row_stride: 100 * 4, // tight RGBA pitch
6470 x: 10,
6471 y: 20
6472 })
6473 );
6474 // A view of a view keeps the ROOT parent and accumulates the origin.
6475 let v2 = v.view(Region::new(5, 5, 10, 10)).unwrap();
6476 assert_eq!(
6477 v2.view_origin(),
6478 Some(ViewOrigin {
6479 parent_width: 100,
6480 parent_height: 80,
6481 parent_row_stride: 100 * 4,
6482 x: 15,
6483 y: 25
6484 }),
6485 "nested view composes onto the root parent"
6486 );
6487 }
6488
6489 #[test]
6490 fn view_origin_none_for_raw_batch() {
6491 // A raw (unformatted) batched tensor has no pixel geometry, so batch()
6492 // leaves view_origin None (the per-slot path, not the one-import pivot).
6493 let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
6494 assert_eq!(parent.batch(2).unwrap().view_origin(), None);
6495 }
6496
6497 #[test]
6498 fn batch_rejects_out_of_bounds_index() {
6499 let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
6500 match parent.batch(4) {
6501 Err(Error::BatchIndexOutOfBounds { index, batch }) => {
6502 assert_eq!((index, batch), (4, 4));
6503 }
6504 other => panic!("expected BatchIndexOutOfBounds, got {other:?}"),
6505 }
6506 }
6507
6508 #[test]
6509 fn batch_zero_on_unit_n_is_whole() {
6510 // N == 1: batch(0) is the whole per-element block at offset 0 (no plane_offset).
6511 let parent = Tensor::<u8>::new(&[1, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
6512 let e = parent.batch(0).unwrap();
6513 assert_eq!(e.shape(), &[2, 2, 3]);
6514 assert_eq!(e.plane_offset(), None);
6515 assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
6516 }
6517
6518 #[test]
6519 fn mem_subview_rejects_unaligned_offset() {
6520 // f32 has align 4; a byte offset of 2 cannot back a valid `*const f32`.
6521 let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
6522 assert!(parent.subview(2, &[1]).is_err());
6523 // A correctly aligned offset is accepted.
6524 assert!(parent.subview(4, &[1]).is_ok());
6525 }
6526
6527 #[test]
6528 fn mem_subview_rejects_out_of_bounds() {
6529 let parent = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
6530 // offset 6 + 4 bytes = 10 exceeds the 8-byte allocation.
6531 assert!(parent.subview(6, &[4]).is_err());
6532 }
6533
6534 /// Regression guard for the `TensorTrait::view` promotion (R2): a `subview`
6535 /// must share the parent's `BufferIdentity` on **every** backend, not mint a
6536 /// fresh one. Identity-keyed caches (the GL EGLImage import) rely on this to
6537 /// treat offset-distinct windows of one buffer as a single import; a fresh
6538 /// identity would silently break that and regress zero-copy import reuse.
6539 ///
6540 /// Runs each backend that can be allocated without a GPU/GL context on the
6541 /// test host: `Mem` (always); `Shm` (when POSIX shm is available); the
6542 /// platform-native zero-copy buffer `Dma` (DMA-BUF on Linux / IOSurface on
6543 /// macOS, when available). `Pbo` shares its identity the same way (see
6544 /// `pbo.rs` `view`) but needs a live GL context, so it is exercised by the
6545 /// image-crate GL tests rather than here.
6546 #[test]
6547 fn subview_shares_buffer_identity_all_backends() {
6548 // Declares this test as an fd-opener; see FD_LOCK.
6549 let _lock = crate::tests::fd_lock_shared();
6550 // u8 has align 1, so every byte offset is valid for the alignment check;
6551 // this isolates the identity-sharing contract from alignment concerns.
6552 let assert_shares = |memory: TensorMemory, label: &str| {
6553 let parent = Tensor::<u8>::new(&[64], Some(memory), None)
6554 .unwrap_or_else(|e| panic!("{label}: parent alloc failed: {e:?}"));
6555 let parent_id = parent.buffer_identity().id();
6556 // Two offset-distinct windows must both carry the parent's identity.
6557 let v0 = parent
6558 .subview(0, &[16])
6559 .unwrap_or_else(|e| panic!("{label}: subview(0) failed: {e:?}"));
6560 let v1 = parent
6561 .subview(16, &[16])
6562 .unwrap_or_else(|e| panic!("{label}: subview(16) failed: {e:?}"));
6563 assert_eq!(
6564 v0.buffer_identity().id(),
6565 parent_id,
6566 "{label}: subview(0) minted a fresh BufferIdentity"
6567 );
6568 assert_eq!(
6569 v1.buffer_identity().id(),
6570 parent_id,
6571 "{label}: subview(16) minted a fresh BufferIdentity"
6572 );
6573 };
6574
6575 assert_shares(TensorMemory::Mem, "Mem");
6576
6577 #[cfg(unix)]
6578 if crate::is_shm_available() {
6579 assert_shares(TensorMemory::Shm, "Shm");
6580 }
6581
6582 // Dma == DMA-BUF on Linux, IOSurface on macOS; same public variant.
6583 if crate::is_gpu_buffer_available() {
6584 assert_shares(TensorMemory::DmaBuf, "Dma");
6585 }
6586 }
6587
6588 #[test]
6589 fn mem_subview_four_views_no_aliasing() {
6590 // One [4,3] f32 parent; four [1,3] views at 12-byte strides, each
6591 // written independently. Exercises a multi-byte element type (offsets
6592 // must stay element-aligned) and N-way zero-copy sharing.
6593 let parent = Tensor::<f32>::new(&[4, 3], Some(TensorMemory::Mem), None).unwrap();
6594 let frame = 3 * std::mem::size_of::<f32>();
6595 for i in 0..4 {
6596 let v = parent.subview(i * frame, &[1, 3]).unwrap();
6597 let val = i as f32 + 1.0;
6598 v.map()
6599 .unwrap()
6600 .as_mut_slice()
6601 .copy_from_slice(&[val, val, val]);
6602 }
6603 assert_eq!(
6604 parent.map().unwrap().as_slice(),
6605 &[1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0]
6606 );
6607 }
6608
6609 #[test]
6610 fn mem_subview_inherits_format_and_row_stride() {
6611 // A sub-view is a ready-to-use sub-image: it inherits the parent's
6612 // pixel format and (crucially) its padded row stride, so a strided
6613 // parent yields strided windows. Set a stride wider than the tight row
6614 // to exercise the row_stride inheritance path specifically.
6615 let mut parent = Tensor::<u8>::image(
6616 100,
6617 100,
6618 PixelFormat::Rgba,
6619 Some(TensorMemory::Mem),
6620 crate::CpuAccess::ReadWrite,
6621 )
6622 .unwrap();
6623 parent.set_row_stride_unchecked(512); // padded stride (> 100*4)
6624 let view = parent.subview(4096, &[10, 10, 4]).unwrap();
6625 assert_eq!(view.format(), Some(PixelFormat::Rgba), "format inherited");
6626 assert_eq!(view.row_stride(), Some(512), "row_stride inherited");
6627 }
6628
6629 #[test]
6630 fn mem_strided_subview_maps_offset_and_byte_size() {
6631 // Integration of the sub-region offset (PR #89) and the strided-map
6632 // `byte_size_override` (PR #90): a strided sub-view exposes its full
6633 // padded window (`row_stride × rows`) starting at the view's byte
6634 // offset, mapped zero-copy into the parent.
6635 let parent = Tensor::<u8>::new(&[2048], Some(TensorMemory::Mem), None).unwrap();
6636 let mut view = parent.subview(128, &[8, 16]).unwrap(); // 8 rows × 16 @ off 128
6637 assert_eq!(view.plane_offset(), Some(128));
6638 view.set_row_stride_unchecked(32); // padded stride (> 16)
6639
6640 {
6641 let mut m = view.map().unwrap();
6642 let s = m.as_mut_slice();
6643 // Strided map exposes the padded window: stride(32) × rows(8) = 256.
6644 assert_eq!(
6645 s.len(),
6646 256,
6647 "strided map exposes the full padded byte window"
6648 );
6649 s[0] = 0xAA; // row 0, col 0
6650 s[32] = 0xBB; // row 1, col 0 (one stride in)
6651 }
6652
6653 // Zero-copy: the writes land in the parent at the view's offset.
6654 let p = parent.map().unwrap();
6655 let pb = p.as_slice();
6656 assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
6657 assert_eq!(
6658 pb[128 + 32],
6659 0xBB,
6660 "row 1 writes at parent offset 128 + stride"
6661 );
6662 }
6663
6664 #[test]
6665 #[cfg(unix)]
6666 fn shm_subview_partitions_parent_buffer() {
6667 // Declares this test as an fd-opener; see FD_LOCK.
6668 let _lock = crate::tests::fd_lock_shared();
6669 // Mirrors `mem_subview_partitions_parent_buffer` for Shm: one [2,4] u8
6670 // parent shared segment (8 bytes); two [1,4] sub-views at byte offsets 0
6671 // and 4 must share the segment (zero-copy, via cloned fd) and be
6672 // independently writable — view 0 owns [0,4), view 1 owns [4,8).
6673 if !crate::is_shm_available() {
6674 eprintln!("SKIPPED: shm not available");
6675 return;
6676 }
6677 let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Shm), None).unwrap();
6678 let view0 = parent.subview(0, &[1, 4]).expect("shm subview at offset 0");
6679 let view1 = parent.subview(4, &[1, 4]).expect("shm subview at offset 4");
6680
6681 view1
6682 .map()
6683 .unwrap()
6684 .as_mut_slice()
6685 .copy_from_slice(&[10, 20, 30, 40]);
6686 view0
6687 .map()
6688 .unwrap()
6689 .as_mut_slice()
6690 .copy_from_slice(&[1, 2, 3, 4]);
6691
6692 assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
6693 assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
6694 // The parent sees the full partitioned segment (shared, zero-copy).
6695 assert_eq!(
6696 parent.map().unwrap().as_slice(),
6697 &[1, 2, 3, 4, 10, 20, 30, 40]
6698 );
6699 // A sub-view of a sub-view composes the offset.
6700 let nested = view1.subview(2, &[1, 2]).expect("nested shm subview");
6701 assert_eq!(nested.map().unwrap().as_slice(), &[30, 40]);
6702 }
6703
6704 #[test]
6705 #[cfg(unix)]
6706 fn shm_subview_rejects_unaligned_and_oob() {
6707 // Declares this test as an fd-opener; see FD_LOCK.
6708 let _lock = crate::tests::fd_lock_shared();
6709 if !crate::is_shm_available() {
6710 eprintln!("SKIPPED: shm not available");
6711 return;
6712 }
6713 // f32 align 4: a 2-byte offset cannot back a valid `*const f32`.
6714 let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
6715 assert!(parent.subview(2, &[1]).is_err());
6716 assert!(parent.subview(4, &[1]).is_ok());
6717 // Out of bounds: offset 6 + 4 bytes = 10 > 8-byte (u8) segment.
6718 let p2 = Tensor::<u8>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
6719 assert!(p2.subview(6, &[4]).is_err());
6720 }
6721
6722 #[test]
6723 #[cfg(target_os = "linux")]
6724 fn dma_subview_matches_mem_subview() {
6725 // Serialize against the fd-leak tests: this test opens DMA fds (alloc +
6726 // clone_fd), which would otherwise perturb their fd counts.
6727 let _lock = crate::tests::fd_lock_shared();
6728 // Identical sub-view semantics across Dma (shared fd) and Mem (shared
6729 // Arc): same offsets → same logical windows → same partition.
6730 let dma = match Tensor::<u8>::new(&[8], Some(TensorMemory::DmaBuf), None) {
6731 Ok(t) => t,
6732 Err(_) => {
6733 eprintln!("SKIPPED: DMA not available");
6734 return;
6735 }
6736 };
6737 let mem = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
6738 for parent in [&dma, &mem] {
6739 let v0 = parent.subview(0, &[4]).unwrap();
6740 let v1 = parent.subview(4, &[4]).unwrap();
6741 v0.map()
6742 .unwrap()
6743 .as_mut_slice()
6744 .copy_from_slice(&[1, 2, 3, 4]);
6745 v1.map()
6746 .unwrap()
6747 .as_mut_slice()
6748 .copy_from_slice(&[5, 6, 7, 8]);
6749 assert_eq!(parent.map().unwrap().as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]);
6750 }
6751 }
6752
6753 #[test]
6754 #[cfg(target_os = "linux")]
6755 fn dma_strided_subview_maps_padded_window() {
6756 // The strided-map path differs by backing: DMA maps through
6757 // `mmap_offset` + the `byte_size_override`, not the Mem `Arc` slice. A
6758 // padded sub-view of a DMA buffer must still expose its full
6759 // `row_stride × rows` window zero-copy at the view's offset (the GPU
6760 // batched-render-to-DMA case). Mirrors
6761 // `mem_strided_subview_maps_offset_and_byte_size` on a Dma parent.
6762 let _lock = crate::tests::fd_lock_shared();
6763 let parent = match Tensor::<u8>::new(&[2048], Some(TensorMemory::DmaBuf), None) {
6764 Ok(t) => t,
6765 Err(_) => {
6766 eprintln!("SKIPPED: DMA not available");
6767 return;
6768 }
6769 };
6770 let mut view = parent.subview(128, &[8, 16]).unwrap();
6771 assert_eq!(view.plane_offset(), Some(128));
6772 view.set_row_stride_unchecked(32); // padded stride (> 16)
6773
6774 {
6775 let mut m = view.map().unwrap();
6776 let s = m.as_mut_slice();
6777 assert_eq!(s.len(), 256, "strided DMA map exposes stride(32) × rows(8)");
6778 s[0] = 0xAA; // row 0, col 0
6779 s[32] = 0xBB; // row 1, col 0 (one stride in)
6780 }
6781
6782 let p = parent.map().unwrap();
6783 let pb = p.as_slice();
6784 assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
6785 assert_eq!(
6786 pb[128 + 32],
6787 0xBB,
6788 "row 1 writes at parent offset 128 + stride"
6789 );
6790 }
6791
6792 #[test]
6793 #[cfg(target_os = "linux")]
6794 fn view_single_row_snapshots_parent_stride() {
6795 // A single-row `view()` keeps a TIGHT `row_stride` for map-span safety,
6796 // but its `view_origin` snapshots the PARENT row stride — the GL backend
6797 // keys its EGLImage import/pitch on that snapshot (not the view's tight
6798 // stride), so single-row and multi-row sibling views collapse onto the
6799 // same parent import.
6800 let _lock = crate::tests::fd_lock_shared();
6801 // 8x4 RGBA with a padded 64-byte row stride (tight row = 8*4 = 32).
6802 let parent = match Tensor::<u8>::image_with_stride(
6803 8,
6804 4,
6805 PixelFormat::Rgba,
6806 64,
6807 Some(TensorMemory::DmaBuf),
6808 crate::CpuAccess::ReadWrite,
6809 ) {
6810 Ok(t) => t,
6811 Err(_) => {
6812 eprintln!("SKIPPED: DMA not available");
6813 return;
6814 }
6815 };
6816 assert_eq!(parent.effective_row_stride(), Some(64));
6817 // Bottom row (y=3) at x>0 — the case the tight single-row stride guards.
6818 let row = parent.view(Region::new(2, 3, 4, 1)).unwrap();
6819 // The view's own stride is tight (4*4 = 16) so its strided map stays in
6820 // bounds; the GL-facing parent pitch (64) lives in `view_origin`.
6821 assert_eq!(row.effective_row_stride(), Some(16));
6822 let vo = row.view_origin().expect("a view carries a view_origin");
6823 assert_eq!(
6824 vo.parent_row_stride, 64,
6825 "GL keys/pitches a view on the parent stride, not its tight one"
6826 );
6827 // The tight stride keeps map() in-bounds for the bottom / x>0 single row.
6828 assert_eq!(row.map().unwrap().as_slice().len(), 16);
6829 }
6830
6831 #[test]
6832 fn test_mem_tensor() {
6833 let shape = vec![2, 3, 4];
6834 let tensor =
6835 MemTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
6836 assert_eq!(tensor.shape(), &shape);
6837 assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
6838 assert_eq!(tensor.name(), "test_tensor");
6839
6840 {
6841 let mut tensor_map = tensor.map().expect("Failed to map memory");
6842 tensor_map.fill(42.0);
6843 assert!(tensor_map.iter().all(|&x| x == 42.0));
6844 }
6845
6846 let mut tensor = MemTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
6847 assert_eq!(tensor.shape(), &shape);
6848 let new_shape = vec![3, 4, 4];
6849 assert!(
6850 tensor.reshape(&new_shape).is_err(),
6851 "Reshape should fail due to size mismatch"
6852 );
6853 assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
6854
6855 let new_shape = vec![2, 3, 4];
6856 tensor.reshape(&new_shape).expect("Reshape should succeed");
6857 assert_eq!(
6858 tensor.shape(),
6859 &new_shape,
6860 "Shape should be updated after successful reshape"
6861 );
6862
6863 {
6864 let mut tensor_map = tensor.map().expect("Failed to map memory");
6865 tensor_map.fill(1);
6866 assert!(tensor_map.iter().all(|&x| x == 1));
6867 }
6868
6869 {
6870 let mut tensor_map = tensor.map().expect("Failed to map memory");
6871 tensor_map[2] = 42;
6872 assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
6873 assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
6874 }
6875 }
6876
6877 #[test]
6878 #[cfg(target_os = "linux")]
6879 fn test_dma_no_fd_leaks() {
6880 let _lock = fd_lock_exclusive();
6881 if !is_dma_available() {
6882 log::warn!(
6883 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
6884 function!()
6885 );
6886 return;
6887 }
6888
6889 let baseline = open_fd_count(DMA_FD_TARGETS);
6890
6891 // Self-check — see `test_shm_no_fd_leaks`.
6892 {
6893 let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
6894 .expect("Failed to create tensor");
6895 assert!(
6896 open_fd_count(DMA_FD_TARGETS) > baseline,
6897 "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
6898 otherwise this test cannot detect a leak"
6899 );
6900 drop(probe);
6901 }
6902
6903 for _ in 0..100 {
6904 let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
6905 .expect("Failed to create tensor");
6906 let mut map = tensor.map().unwrap();
6907 map.as_mut_slice().fill(233);
6908 }
6909
6910 let after = open_fd_count(DMA_FD_TARGETS);
6911 assert_eq!(
6912 baseline, after,
6913 "DMA-BUF file descriptor leak detected: {baseline} -> {after}"
6914 );
6915 }
6916
6917 #[test]
6918 #[cfg(target_os = "linux")]
6919 fn test_dma_from_fd_no_fd_leaks() {
6920 let _lock = fd_lock_exclusive();
6921 if !is_dma_available() {
6922 log::warn!(
6923 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
6924 function!()
6925 );
6926 return;
6927 }
6928
6929 let baseline = open_fd_count(DMA_FD_TARGETS);
6930 let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None).unwrap();
6931
6932 // Self-check — see `test_shm_no_fd_leaks`.
6933 let held = open_fd_count(DMA_FD_TARGETS);
6934 assert!(
6935 held > baseline,
6936 "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
6937 otherwise this test cannot detect a leak"
6938 );
6939
6940 for _ in 0..100 {
6941 let tensor =
6942 Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
6943 assert_eq!(
6944 tensor.memory(),
6945 TensorMemory::DmaBuf,
6946 "DMA-BUF fd must import as Dma, not be silently downgraded"
6947 );
6948 let mut map = tensor.map().unwrap();
6949 map.as_mut_slice().fill(233);
6950 }
6951
6952 let after_loop = open_fd_count(DMA_FD_TARGETS);
6953 assert_eq!(
6954 held, after_loop,
6955 "DMA-BUF file descriptor leak detected: {held} -> {after_loop}"
6956 );
6957
6958 // And the source tensor's own fd goes back on drop.
6959 drop(orig);
6960 let after_drop = open_fd_count(DMA_FD_TARGETS);
6961 assert_eq!(
6962 baseline, after_drop,
6963 "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
6964 );
6965 }
6966
6967 /// A filesystem magic must report its true 32-bit value regardless of
6968 /// how wide, and how signed, `fstatfs`'s `f_type` is on the target.
6969 ///
6970 /// `fs_type_t` is `__fsword_t` on Linux/gnu — `i64` on 64-bit but `i32`
6971 /// on 32-bit (armv7, i686, aarch64-ilp32) — `c_int` on uclibc, and
6972 /// unsigned on musl and s390x. Widening a *signed* 32-bit `f_type`
6973 /// sign-extends every magic with bit 31 set, so a naive widening cast
6974 /// reports e.g. `0xffffffff958458f6` for hugetlbfs. The docs tell
6975 /// callers to look the reported value up in `include/uapi/linux/magic.h`,
6976 /// so a sign-extended value is actively misleading.
6977 ///
6978 /// This cannot be reproduced on a 64-bit host — the defect only exists
6979 /// where `f_type` is a signed 32-bit type — so the test drives the
6980 /// conversion directly with the value such a target would produce.
6981 #[test]
6982 #[cfg(target_os = "linux")]
6983 fn test_fs_magic_normalizes_sign_extended_values() {
6984 // Declares this test as an fd-opener; see FD_LOCK.
6985 let _lock = crate::tests::fd_lock_shared();
6986 // Magics whose bit 31 is set. HUGETLBFS is the one that matters
6987 // most in practice: a MFD_HUGETLB memfd is the likeliest fd to land
6988 // in the UnknownBufferType arm.
6989 const HUGETLBFS_MAGIC: u32 = 0x9584_58f6;
6990 const F2FS_MAGIC: u32 = 0xf2f5_2010;
6991 const BTRFS_MAGIC: u32 = 0x9123_683e;
6992
6993 for magic in [HUGETLBFS_MAGIC, F2FS_MAGIC, BTRFS_MAGIC] {
6994 // 64-bit gnu: f_type is i64 and already holds the true value.
6995 assert_eq!(fs_magic(i64::from(magic)), magic);
6996
6997 // 32-bit gnu / uclibc: f_type is i32, so the value arrives
6998 // sign-extended once widened. It must still report its true
6999 // 32 bits.
7000 let sign_extended = i64::from(magic as i32);
7001 assert!(sign_extended < 0, "{magic:#x} should have bit 31 set");
7002 assert_eq!(
7003 fs_magic(sign_extended),
7004 magic,
7005 "{magic:#x} must survive a signed 32-bit f_type"
7006 );
7007 }
7008
7009 // The two magics we actually classify on are below 2^31, so they are
7010 // unaffected by signedness either way — this is why the DMA-vs-SHM
7011 // decision is correct on 32-bit even without this normalization.
7012 for magic in [DMA_BUF_MAGIC, TMPFS_MAGIC] {
7013 assert!(magic < 0x8000_0000);
7014 assert_eq!(fs_magic(i64::from(magic as i32)), magic);
7015 }
7016 }
7017
7018 /// A DMA-BUF fd must import as [`TensorMemory::DmaBuf`].
7019 ///
7020 /// Regression test for the `st_dev` minor-number classifier, which
7021 /// hardcoded `9 | 10` as "this is DMA". Those minors come from
7022 /// `get_anon_bdev()` and are assigned first-come-first-served at boot,
7023 /// so they vary by kernel build and boot order — a real DMA-BUF is
7024 /// minor 12 on x86 desktop and minor 8 on the ADIS Verdin. Both fell
7025 /// into the `_` arm and imported as SHM, which "works" (a DMA-BUF is
7026 /// mmap-able) but silently forfeits zero-copy.
7027 #[test]
7028 #[cfg(target_os = "linux")]
7029 fn test_from_fd_dma_imports_as_dma() {
7030 let _lock = crate::tests::fd_lock_shared();
7031 if !is_dma_available() {
7032 log::warn!("SKIPPED: {} - DMA memory not available", function!());
7033 return;
7034 }
7035
7036 let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::DmaBuf), None).unwrap();
7037 assert_eq!(orig.memory(), TensorMemory::DmaBuf);
7038
7039 let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
7040
7041 assert_eq!(
7042 imported.memory(),
7043 TensorMemory::DmaBuf,
7044 "a DMA-BUF fd must import as Dma"
7045 );
7046 }
7047
7048 /// A tmpfs/SHM fd must import as [`TensorMemory::Shm`].
7049 ///
7050 /// The companion to `test_from_fd_dma_imports_as_dma`: confirms the
7051 /// magic-based classifier identifies SHM positively rather than by
7052 /// falling through.
7053 #[test]
7054 #[cfg(target_os = "linux")]
7055 fn test_from_fd_shm_imports_as_shm() {
7056 let _lock = crate::tests::fd_lock_shared();
7057 if !is_shm_available() {
7058 log::warn!("SKIPPED: {} - SHM memory not available", function!());
7059 return;
7060 }
7061
7062 let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::Shm), None).unwrap();
7063 assert_eq!(orig.memory(), TensorMemory::Shm);
7064
7065 let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
7066
7067 assert_eq!(
7068 imported.memory(),
7069 TensorMemory::Shm,
7070 "a tmpfs fd must import as Shm"
7071 );
7072 }
7073
7074 /// An fd that is neither a DMA-BUF nor tmpfs must be rejected.
7075 ///
7076 /// A pipe is the convenient probe: it lives on `pipefs`, another
7077 /// `get_anon_bdev()` pseudo-filesystem, so it shares major 0 with the
7078 /// buffer types we do support and is only distinguishable by magic.
7079 /// Importing one as SHM is meaningless — `mmap` on a pipe fails — so
7080 /// the classifier must say "unknown" rather than guess.
7081 #[test]
7082 #[cfg(target_os = "linux")]
7083 fn test_from_fd_rejects_unknown_filesystem() {
7084 let _lock = crate::tests::fd_lock_shared();
7085
7086 let (read_end, _write_end) = nix::unistd::pipe().unwrap();
7087
7088 let result = Tensor::<u8>::from_fd(read_end, &[64], None);
7089
7090 match result {
7091 Err(Error::UnknownBufferType(magic)) => {
7092 // PIPEFS_MAGIC, from include/uapi/linux/magic.h
7093 assert_eq!(magic, 0x5049_5045, "expected PIPEFS_MAGIC");
7094 }
7095 other => panic!("expected UnknownBufferType for a pipe fd, got {other:?}"),
7096 }
7097 }
7098
7099 #[test]
7100 #[cfg(target_os = "linux")]
7101 fn test_shm_no_fd_leaks() {
7102 let _lock = fd_lock_exclusive();
7103 if !is_shm_available() {
7104 log::warn!(
7105 "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
7106 function!()
7107 );
7108 return;
7109 }
7110
7111 let baseline = open_fd_count(SHM_FD_TARGETS);
7112
7113 // Self-check: the counter must actually see a live shm tensor. If
7114 // it does not — a changed backing object, a different `/proc`
7115 // rendering — the leak assertion below would hold no matter how
7116 // much leaked, so fail loudly here instead.
7117 {
7118 let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
7119 .expect("Failed to create tensor");
7120 assert!(
7121 open_fd_count(SHM_FD_TARGETS) > baseline,
7122 "a live shm tensor must be visible to the shm fd counter, \
7123 otherwise this test cannot detect a leak"
7124 );
7125 drop(probe);
7126 }
7127
7128 for _ in 0..100 {
7129 let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
7130 .expect("Failed to create tensor");
7131 let mut map = tensor.map().unwrap();
7132 map.as_mut_slice().fill(233);
7133 }
7134
7135 let after = open_fd_count(SHM_FD_TARGETS);
7136 assert_eq!(
7137 baseline, after,
7138 "shm file descriptor leak detected: {baseline} -> {after}"
7139 );
7140 }
7141
7142 #[test]
7143 #[cfg(target_os = "linux")]
7144 fn test_shm_from_fd_no_fd_leaks() {
7145 let _lock = fd_lock_exclusive();
7146 if !is_shm_available() {
7147 log::warn!(
7148 "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
7149 function!()
7150 );
7151 return;
7152 }
7153
7154 let baseline = open_fd_count(SHM_FD_TARGETS);
7155 let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None).unwrap();
7156
7157 // Self-check — see `test_shm_no_fd_leaks`.
7158 let held = open_fd_count(SHM_FD_TARGETS);
7159 assert!(
7160 held > baseline,
7161 "a live shm tensor must be visible to the shm fd counter, \
7162 otherwise this test cannot detect a leak"
7163 );
7164
7165 for _ in 0..100 {
7166 let tensor =
7167 Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
7168 let mut map = tensor.map().unwrap();
7169 map.as_mut_slice().fill(233);
7170 }
7171
7172 let after_loop = open_fd_count(SHM_FD_TARGETS);
7173 assert_eq!(
7174 held, after_loop,
7175 "shm file descriptor leak detected: {held} -> {after_loop}"
7176 );
7177
7178 // And the source tensor's own fd goes back on drop.
7179 drop(orig);
7180 let after_drop = open_fd_count(SHM_FD_TARGETS);
7181 assert_eq!(
7182 baseline, after_drop,
7183 "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
7184 );
7185 }
7186
7187 #[cfg(feature = "ndarray")]
7188 #[test]
7189 fn test_ndarray() {
7190 let _lock = crate::tests::fd_lock_shared();
7191 let shape = vec![2, 3, 4];
7192 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7193
7194 let mut tensor_map = tensor.map().expect("Failed to map tensor memory");
7195 tensor_map.fill(1.0);
7196
7197 let view = tensor_map.view().expect("Failed to get ndarray view");
7198 assert_eq!(view.shape(), &[2, 3, 4]);
7199 assert!(view.iter().all(|&x| x == 1.0));
7200
7201 let mut view_mut = tensor_map
7202 .view_mut()
7203 .expect("Failed to get mutable ndarray view");
7204 view_mut[[0, 0, 0]] = 42.0;
7205 assert_eq!(view_mut[[0, 0, 0]], 42.0);
7206 assert_eq!(tensor_map[0], 42.0, "Value at index 0 should be 42");
7207 }
7208
7209 #[test]
7210 fn test_buffer_identity_derived_equality() {
7211 // Two identities derived from the same kind and key must be equal —
7212 // that is the whole point of `derived`: two independently-linked
7213 // copies of this crate, each importing the same underlying buffer,
7214 // have to agree on its id without talking to each other.
7215 let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
7216 let id2 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
7217 assert_eq!(
7218 id1.id(),
7219 id2.id(),
7220 "Identities derived from the same kind and key should match"
7221 );
7222
7223 // A different key still yields a different id.
7224 let id3 = BufferIdentity::derived(IdentityKind::DmaBuf, 4243);
7225 assert_ne!(
7226 id1.id(),
7227 id3.id(),
7228 "Identities derived from different keys should differ"
7229 );
7230 }
7231
7232 #[test]
7233 fn test_buffer_identity_clone_shares_guard() {
7234 let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
7235 let weak = id1.weak();
7236 assert!(
7237 weak.upgrade().is_some(),
7238 "Weak should be alive while original exists"
7239 );
7240
7241 let id2 = id1.clone();
7242 assert_eq!(id1.id(), id2.id(), "Cloned identity should have same id");
7243
7244 drop(id1);
7245 assert!(
7246 weak.upgrade().is_some(),
7247 "Weak should still be alive (clone holds Arc)"
7248 );
7249
7250 drop(id2);
7251 assert!(
7252 weak.upgrade().is_none(),
7253 "Weak should be dead after all clones dropped"
7254 );
7255 }
7256
7257 #[test]
7258 fn test_tensor_buffer_identity() {
7259 let t1 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t1")).unwrap();
7260 let t2 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t2")).unwrap();
7261 assert_ne!(
7262 t1.buffer_identity().id(),
7263 t2.buffer_identity().id(),
7264 "Different tensors should have different buffer ids"
7265 );
7266 }
7267
7268 // ------------------------------------------------------------------------
7269 // Quantization — constructor validation + accessor correctness.
7270 // ------------------------------------------------------------------------
7271
7272 #[test]
7273 fn test_quantization_per_tensor_constructors() {
7274 let q = Quantization::per_tensor(0.1, -5);
7275 assert!(q.is_per_tensor());
7276 assert!(!q.is_per_channel());
7277 assert!(!q.is_symmetric());
7278 assert_eq!(q.scale(), &[0.1]);
7279 assert_eq!(q.zero_point(), Some(&[-5][..]));
7280
7281 let qs = Quantization::per_tensor_symmetric(0.05);
7282 assert!(qs.is_per_tensor());
7283 assert!(qs.is_symmetric());
7284 assert_eq!(qs.zero_point(), None);
7285 }
7286
7287 #[test]
7288 fn test_quantization_per_channel_constructors() {
7289 let q = Quantization::per_channel(vec![0.1, 0.2, 0.3], vec![0, -1, 1], 2).unwrap();
7290 assert!(q.is_per_channel());
7291 assert!(!q.is_symmetric());
7292 assert_eq!(q.axis(), Some(2));
7293 assert_eq!(q.scale().len(), 3);
7294
7295 let qs = Quantization::per_channel_symmetric(vec![0.054, 0.089, 0.195], 0).unwrap();
7296 assert!(qs.is_per_channel());
7297 assert!(qs.is_symmetric());
7298 assert_eq!(qs.axis(), Some(0));
7299 }
7300
7301 #[test]
7302 fn test_quantization_per_channel_length_mismatch_rejected() {
7303 // len(scales) != len(zero_points) → rejected at construction.
7304 let err = Quantization::per_channel(vec![0.1, 0.2], vec![0, 0, 0], 0).unwrap_err();
7305 assert!(matches!(err, Error::QuantizationInvalid { .. }));
7306 }
7307
7308 #[test]
7309 fn test_quantization_per_channel_empty_rejected() {
7310 let err = Quantization::per_channel_symmetric(vec![], 0).unwrap_err();
7311 assert!(matches!(err, Error::QuantizationInvalid { .. }));
7312 }
7313
7314 /// Constructors guard scale/zero_point length invariants, but
7315 /// `Quantization` is `Deserialize`, so malformed JSON (e.g. an
7316 /// empty `scale` array, or `zero_point` length that disagrees with
7317 /// `scale`) bypasses the constructor checks. `set_quantization`
7318 /// must reject these via `validate()` so they don't poison
7319 /// downstream `mode()` selection or per-channel kernel indexing.
7320 #[test]
7321 fn test_quantization_validate_rejects_malformed_deserialize() {
7322 let mut t = Tensor::<i8>::new(&[1, 1, 4], Some(TensorMemory::Mem), None).unwrap();
7323
7324 // Empty scale array: must be rejected.
7325 let q: Quantization = serde_json::from_str(r#"{"scale": []}"#).unwrap();
7326 assert!(matches!(
7327 t.set_quantization(q).unwrap_err(),
7328 Error::QuantizationInvalid { .. }
7329 ));
7330
7331 // Per-tensor with multi-element zero_point: must be rejected.
7332 let q: Quantization =
7333 serde_json::from_str(r#"{"scale": 0.1, "zero_point": [0, 0, 0]}"#).unwrap();
7334 assert!(matches!(
7335 t.set_quantization(q).unwrap_err(),
7336 Error::QuantizationInvalid { .. }
7337 ));
7338
7339 // Per-channel zero_point length != scale length: must be rejected.
7340 let q: Quantization = serde_json::from_str(
7341 r#"{"scale": [0.1, 0.2, 0.3, 0.4], "zero_point": [0, 0], "axis": 2}"#,
7342 )
7343 .unwrap();
7344 assert!(matches!(
7345 t.set_quantization(q).unwrap_err(),
7346 Error::QuantizationInvalid { .. }
7347 ));
7348 }
7349
7350 #[test]
7351 fn test_quantization_mode_dispatch() {
7352 let pt = Quantization::per_tensor(0.1, -5);
7353 assert!(matches!(
7354 pt.mode(),
7355 QuantMode::PerTensor { scale, zero_point } if scale == 0.1 && zero_point == -5
7356 ));
7357
7358 let pts = Quantization::per_tensor_symmetric(0.05);
7359 assert!(matches!(
7360 pts.mode(),
7361 QuantMode::PerTensorSymmetric { scale } if scale == 0.05
7362 ));
7363
7364 let pc = Quantization::per_channel(vec![0.1, 0.2], vec![0, -1], 2).unwrap();
7365 assert!(matches!(pc.mode(), QuantMode::PerChannel { axis: 2, .. }));
7366
7367 let pcs = Quantization::per_channel_symmetric(vec![0.1, 0.2], 0).unwrap();
7368 assert!(matches!(
7369 pcs.mode(),
7370 QuantMode::PerChannelSymmetric { axis: 0, .. }
7371 ));
7372 }
7373
7374 #[test]
7375 fn test_tensor_quantization_roundtrip_integer() {
7376 let mut t = Tensor::<i8>::new(&[2, 3, 4], Some(TensorMemory::Mem), None).unwrap();
7377 assert!(t.quantization().is_none());
7378 t.set_quantization(Quantization::per_tensor(0.1, -5))
7379 .unwrap();
7380 let q = t.quantization().unwrap();
7381 assert_eq!(q.scale(), &[0.1]);
7382 t.clear_quantization();
7383 assert!(t.quantization().is_none());
7384 }
7385
7386 #[test]
7387 fn test_tensor_with_quantization_builder() {
7388 let t = Tensor::<i8>::new(&[4, 4], Some(TensorMemory::Mem), None)
7389 .unwrap()
7390 .with_quantization(Quantization::per_tensor_symmetric(0.05))
7391 .unwrap();
7392 assert!(t.quantization().is_some());
7393 }
7394
7395 #[test]
7396 fn test_tensor_dyn_quantization_float_arm_returns_none() {
7397 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
7398 let td = TensorDyn::F32(t);
7399 assert!(td.quantization().is_none());
7400 }
7401
7402 #[test]
7403 fn test_tensor_dyn_set_quantization_float_arm_errors() {
7404 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
7405 let mut td = TensorDyn::F32(t);
7406 let err = td
7407 .set_quantization(Quantization::per_tensor(0.1, 0))
7408 .unwrap_err();
7409 // float path returns a QuantizationInvalid error.
7410 assert!(matches!(err, Error::QuantizationInvalid { .. }));
7411 }
7412
7413 /// Compile-time type gate — calling `Tensor::<f32>::quantization()` must
7414 /// fail to compile (the `IntegerType` trait bound is not satisfied by
7415 /// `f32`). This doctest anchors the invariant.
7416 ///
7417 /// ```compile_fail
7418 /// use edgefirst_tensor::{Tensor, TensorMemory};
7419 /// let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
7420 /// let _ = t.quantization(); // compile error: f32 not IntegerType
7421 /// ```
7422 fn _compile_fail_doctest_anchor() {}
7423
7424 // Any test that cares about the fd count must grab it exclusively.
7425 // Any tests which modifies the fd count by opening or closing fds must grab it
7426 // shared.
7427 pub static FD_LOCK: RwLock<()> = RwLock::new(());
7428
7429 /// Take [`FD_LOCK`] shared, tolerating poisoning.
7430 ///
7431 /// A test that panics while holding the lock poisons it, and every later
7432 /// `.unwrap()` on it panics too — one real failure turns into a handful
7433 /// of unrelated ones that bury it. The guarded data is `()`, so a panic
7434 /// cannot have left an invariant broken and there is nothing to protect
7435 /// against by refusing the lock.
7436 pub fn fd_lock_shared() -> std::sync::RwLockReadGuard<'static, ()> {
7437 FD_LOCK.read().unwrap_or_else(PoisonError::into_inner)
7438 }
7439
7440 /// Take [`FD_LOCK`] exclusively. Poison-tolerant, see [`fd_lock_shared`].
7441 ///
7442 /// Linux-only, like all four of its callers: the fd-leak tests read fd
7443 /// state out of `/proc`, which no other platform provides. Ungated, it
7444 /// is dead code on macOS and the lint denies that.
7445 /// [`fd_lock_shared`] stays available everywhere — macOS tests declare
7446 /// fd activity too.
7447 #[cfg(target_os = "linux")]
7448 pub fn fd_lock_exclusive() -> std::sync::RwLockWriteGuard<'static, ()> {
7449 FD_LOCK.write().unwrap_or_else(PoisonError::into_inner)
7450 }
7451
7452 /// `/proc` fd-target fragments identifying a POSIX shared memory
7453 /// segment. `shm_open` then `shm_unlink` leaves the target rendered as
7454 /// `/dev/shm/<name> (deleted)`.
7455 #[cfg(target_os = "linux")]
7456 const SHM_FD_TARGETS: &[&str] = &["/dev/shm/"];
7457
7458 /// `/proc` fd-target fragments identifying the fds a DMA tensor owns.
7459 ///
7460 /// Both the exported buffer and the heap it came from are listed, so a
7461 /// leak of either is still caught — narrowing this to the buffer alone
7462 /// would silently stop covering leaked heap handles.
7463 #[cfg(target_os = "linux")]
7464 const DMA_FD_TARGETS: &[&str] = &["dmabuf", "dma_heap"];
7465
7466 /// How many of this process's open fds point at an object whose `/proc`
7467 /// target contains `needle`.
7468 ///
7469 /// The fd-leak tests deliberately do NOT compare total fd counts. Under
7470 /// `cargo test` every test in this crate runs as a thread in one
7471 /// process, and roughly twenty of them create shm or DMA tensors —
7472 /// while anything else may open a file, a socket, or a pipe — so an
7473 /// unrelated test opening or closing an fd inside the measurement
7474 /// window moved the total and failed the assertion. Worse, the panic
7475 /// poisoned [`FD_LOCK`] and took four more tests down with it, so the
7476 /// visible failure had little to do with the cause.
7477 ///
7478 /// Counting only fds that name the object under test makes the
7479 /// measurement immune to every unrelated fd, while still catching the
7480 /// leak each test exists to catch. (CI runs nextest, one process per
7481 /// test, which is why this only ever flaked when run locally.)
7482 #[cfg(target_os = "linux")]
7483 fn open_fd_count(needles: &[&str]) -> usize {
7484 let proc = procfs::process::Process::myself()
7485 .expect("Failed to get current process using /proc/self");
7486 proc.fd()
7487 .expect("Failed to list open file descriptors")
7488 .filter_map(Result::ok)
7489 .filter(|entry| {
7490 let target = format!("{:?}", entry.target);
7491 needles.iter().any(|needle| target.contains(needle))
7492 })
7493 .count()
7494 }
7495
7496 /// Test that DMA is NOT available on non-Linux platforms.
7497 /// This verifies the cross-platform behavior of is_dma_available().
7498 #[test]
7499 #[cfg(not(target_os = "linux"))]
7500 fn test_dma_not_available_on_non_linux() {
7501 assert!(
7502 !is_dma_available(),
7503 "DMA memory allocation should NOT be available on non-Linux platforms"
7504 );
7505 }
7506
7507 #[test]
7508 fn colorimetry_defaults_none_and_roundtrips_without_auto_fill() {
7509 use crate::{ColorEncoding, ColorRange, Colorimetry, PixelFormat, TensorMemory};
7510 let mut t = Tensor::<u8>::image(
7511 1280,
7512 720,
7513 PixelFormat::Nv12,
7514 Some(TensorMemory::Mem),
7515 crate::CpuAccess::ReadWrite,
7516 )
7517 .unwrap();
7518 assert_eq!(t.colorimetry(), None); // default undefined
7519 let c = Colorimetry::default()
7520 .with_encoding(ColorEncoding::Bt709)
7521 .with_range(ColorRange::Limited);
7522 t.set_colorimetry(Some(c));
7523 assert_eq!(t.colorimetry(), Some(c));
7524 // configure_image must NOT touch colorimetry
7525 t.configure_image(640, 480, PixelFormat::Grey).unwrap();
7526 assert_eq!(t.colorimetry(), Some(c));
7527 }
7528
7529 #[test]
7530 fn configure_image_within_capacity() {
7531 let mut t = Tensor::<u8>::image_with_capacity(
7532 640,
7533 480,
7534 PixelFormat::Rgb,
7535 None,
7536 crate::CpuAccess::ReadWrite,
7537 )
7538 .unwrap();
7539 t.configure_image(320, 240, PixelFormat::Nv12).unwrap();
7540 assert_eq!(t.format(), Some(PixelFormat::Nv12));
7541 assert_eq!(t.width(), Some(320));
7542 assert_eq!(t.height(), Some(240));
7543 assert_eq!(t.shape(), &[360, 320]); // 240*3/2
7544 }
7545
7546 #[test]
7547 fn configure_image_too_large_errors() {
7548 let mut t = Tensor::<u8>::image_with_capacity(
7549 64,
7550 64,
7551 PixelFormat::Grey,
7552 None,
7553 crate::CpuAccess::ReadWrite,
7554 )
7555 .unwrap();
7556 let err = t
7557 .configure_image(1920, 1080, PixelFormat::Nv12)
7558 .unwrap_err();
7559 assert!(matches!(err, Error::InsufficientCapacity { .. }));
7560 }
7561
7562 /// A reused max-sized IOSurface pool keeps its physical `bytesPerRow` when
7563 /// reconfigured to a smaller logical image (physical-grid / logical-ROI
7564 /// decoupling), instead of collapsing to the frame's natural row stride.
7565 #[test]
7566 #[cfg(target_os = "macos")]
7567 fn configure_image_preserves_iosurface_physical_stride() {
7568 // Declares this test as an fd-opener; see FD_LOCK.
7569 let _lock = crate::tests::fd_lock_shared();
7570 // Pool: GREY/R8 IOSurface 100 wide → bytesPerRow padded to 128.
7571 let mut pool = Tensor::<u8>::image(
7572 100,
7573 64,
7574 PixelFormat::Grey,
7575 Some(TensorMemory::DmaBuf),
7576 crate::CpuAccess::ReadWrite,
7577 )
7578 .unwrap();
7579 let pitch = pool.effective_row_stride().unwrap();
7580 assert!(
7581 pitch >= 128 && pitch.is_multiple_of(64),
7582 "padded bytesPerRow, got {pitch}"
7583 );
7584
7585 // Reconfigure to a smaller NV12 frame; the physical pitch must survive
7586 // (natural would be 32, but the surface stride is the 128-padded pitch).
7587 pool.configure_image(32, 16, PixelFormat::Nv12).unwrap();
7588 assert_eq!(pool.format(), Some(PixelFormat::Nv12));
7589 assert_eq!(pool.width(), Some(32));
7590 assert_eq!(pool.height(), Some(16));
7591 assert_eq!(
7592 pool.effective_row_stride(),
7593 Some(pitch),
7594 "configure_image must preserve the IOSurface physical bytesPerRow"
7595 );
7596
7597 // Reconfigure again to NV24 — pitch still preserved.
7598 pool.configure_image(32, 16, PixelFormat::Nv24).unwrap();
7599 assert_eq!(pool.effective_row_stride(), Some(pitch));
7600 }
7601
7602 /// `configure_image` on a Mem backing reconfigures to the format's
7603 /// **64-byte-aligned** row stride (the odd-dim contract: every image tensor
7604 /// carries a 64-aligned `row_stride`). For NV12 32×16 the minimum is
7605 /// `even(32)=32`, rounded up to the 64-byte alignment → 64. The capacity
7606 /// (64×64×4 RGBA = 16 KiB) easily holds the 24×64 = 1.5 KiB NV12 layout.
7607 #[test]
7608 fn configure_image_mem_aligns_stride() {
7609 let mut t = Tensor::<u8>::image_with_capacity(
7610 64,
7611 64,
7612 PixelFormat::Rgba,
7613 Some(TensorMemory::Mem),
7614 crate::CpuAccess::ReadWrite,
7615 )
7616 .unwrap();
7617 t.configure_image(32, 16, PixelFormat::Nv12).unwrap();
7618 let s = t.effective_row_stride().unwrap();
7619 assert_eq!(s % 64, 0, "stride must be 64-aligned");
7620 assert!(s >= 32, "stride must cover the even-width minimum");
7621 assert_eq!(s, 64);
7622 }
7623
7624 #[test]
7625 fn strided_mem_tensor_cpu_maps_full_padded_buffer() {
7626 // A packed RGBA image with row padding (GPU-pitch style): logical width
7627 // 8 px (32 B/row) but a 48-byte row stride. Over-allocate capacity (for
7628 // 16 px), narrow the logical width, then record the padded stride.
7629 // Previously `map()` rejected this on non-Linux with
7630 // "DMA backing is Linux-only"; HAL-owned Mem is now mappable.
7631 let mut t = Tensor::<u8>::image_with_capacity(
7632 16,
7633 3,
7634 PixelFormat::Rgba,
7635 Some(TensorMemory::Mem),
7636 crate::CpuAccess::ReadWrite,
7637 )
7638 .unwrap(); // capacity 3 × 16 × 4 = 192 B
7639 t.configure_image(8, 3, PixelFormat::Rgba).unwrap(); // logical [3, 8, 4] = 96 B
7640 t.set_row_stride(48).unwrap(); // padded stride (>= 32 B min)
7641
7642 let map = t.map().expect("strided Mem tensor should CPU-map");
7643 // Full padded buffer (stride 48 × 3 rows = 144 B), not the 96 B logical
7644 // view — callers iterate rows via `effective_row_stride()`.
7645 assert_eq!(map.as_slice().len(), 144);
7646 // Logical shape is still reported for shape-aware consumers.
7647 assert_eq!(map.shape(), &[3, 8, 4]);
7648 }
7649
7650 #[test]
7651 fn strided_mem_tensor_over_capacity_errors() {
7652 // Stride larger than the allocation: 64 B × 3 rows = 192 B > 96 B cap.
7653 let mut t = Tensor::<u8>::new(&[3, 8, 4], Some(TensorMemory::Mem), None).unwrap();
7654 t.set_format(PixelFormat::Rgba).unwrap();
7655 t.set_row_stride(64).unwrap();
7656 assert!(matches!(t.map(), Err(Error::InsufficientCapacity { .. })));
7657 }
7658
7659 /// Test that SHM memory allocation is available and usable on Unix systems.
7660 /// This is a basic functional test; Linux has additional FD leak tests using procfs.
7661 #[test]
7662 #[cfg(unix)]
7663 fn test_shm_available_and_usable() {
7664 // Declares this test as an fd-opener; see FD_LOCK.
7665 let _lock = crate::tests::fd_lock_shared();
7666 // "Unix" is not one platform here: Android is unix but its bionic libc
7667 // has no POSIX shared memory, so allocation legitimately reports
7668 // NotImplemented (see shm.rs module docs). Asserting availability
7669 // outright fails there for a documented reason.
7670 if !is_shm_available() {
7671 log::warn!(
7672 "SKIPPED: test_shm_available_and_usable - SHM allocation \
7673 unavailable on this platform"
7674 );
7675 return;
7676 }
7677
7678 // Create a tensor with SHM backing
7679 let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
7680 .expect("Failed to create SHM tensor");
7681
7682 // Verify we can map and write to it
7683 let mut map = tensor.map().expect("Failed to map SHM tensor");
7684 map.as_mut_slice().fill(0xAB);
7685
7686 // Verify the data was written correctly
7687 assert!(
7688 map.as_slice().iter().all(|&b| b == 0xAB),
7689 "SHM tensor data should be writable and readable"
7690 );
7691 }
7692
7693 // =========================================================================
7694 // packed_rgba16f_layout — host-runnable geometry unit tests (TDD)
7695 // =========================================================================
7696
7697 #[test]
7698 fn packed_rgba16f_layout_planar_rgb_f16() {
7699 let layout =
7700 packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 640).expect("Some");
7701 assert_eq!(layout.surface_w, 160);
7702 assert_eq!(layout.surface_h, 1920);
7703 assert_eq!(layout.bytes_per_texel, 8);
7704 assert_eq!(layout.pitch, 1280);
7705 }
7706
7707 #[test]
7708 fn packed_rgba16f_layout_planar_rgba_f16() {
7709 let layout =
7710 packed_rgba16f_layout(PixelFormat::PlanarRgba, DType::F16, 640, 640).expect("Some");
7711 assert_eq!(layout.surface_w, 160);
7712 assert_eq!(layout.surface_h, 2560); // 4 planes
7713 assert_eq!(layout.bytes_per_texel, 8);
7714 assert_eq!(layout.pitch, 1280);
7715 }
7716
7717 #[test]
7718 fn packed_rgba16f_layout_rejects_misaligned() {
7719 assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 642, 640).is_none());
7720 }
7721
7722 #[test]
7723 fn packed_rgba16f_layout_rejects_non_f16() {
7724 // Non-F16 dtype with planar RGB
7725 assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::U8, 640, 640).is_none());
7726 // Non-planar format with F32
7727 assert!(packed_rgba16f_layout(PixelFormat::Rgb, DType::F32, 640, 640).is_none());
7728 // Packed Rgba with F16 is not a planar format → None
7729 assert!(packed_rgba16f_layout(PixelFormat::Rgba, DType::F16, 640, 640).is_none());
7730 }
7731
7732 #[test]
7733 fn cuda_map_fast_fails_to_none_without_handle() {
7734 let t = Tensor::<f32>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
7735 assert!(t.cuda().is_none());
7736 assert!(t.cuda_map().is_none()); // pure local check, no GL routing
7737 }
7738
7739 #[test]
7740 fn cuda_returns_none_without_handle() {
7741 // A plain Mem-backed tensor has no CUDA handle attached.
7742 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
7743 assert!(t.cuda().is_none(), "no CUDA handle on a Mem tensor");
7744 assert!(t.cuda_map().is_none(), "fast-fail map → None");
7745 }
7746
7747 #[test]
7748 fn cuda_map_then_host_map_fallback() {
7749 // The documented client pattern: try cuda_map() first; when it is None
7750 // (no CUDA handle — the case for a plain Mem tensor), fall back to map().
7751 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
7752 // Bind to a named variable so the CudaMap guard (and its borrow of `t`)
7753 // is dropped at the end of this statement, before the else branch borrows `t` again.
7754 let cuda = t.cuda_map();
7755 if let Some(_c) = cuda {
7756 // On a CUDA-registered tensor we'd use the device ptr here.
7757 unreachable!("a Mem tensor has no CUDA handle");
7758 } else {
7759 let host = t.map().expect("host map fallback must succeed");
7760 // TensorMapTrait::len() returns the element count (not bytes).
7761 assert_eq!(host.len(), 4); // 2*2 f32 elements
7762 }
7763 }
7764
7765 // -------------------------------------------------------------------------
7766 // Tensor::from_foreign — public API tests at the Tensor<T> layer.
7767 //
7768 // The low-level MemTensor::from_foreign mechanics (owner-drop, view sharing)
7769 // are covered in mem.rs. These tests exercise the Tensor<T> guard paths
7770 // (null ptr, empty shape, size overflow) and the basic wrap+readback
7771 // contract, confirming the public unsafe API wires through correctly.
7772 // -------------------------------------------------------------------------
7773
7774 #[test]
7775 fn from_foreign_valid_wrap_and_readback() {
7776 // The canonical CUDA zero-copy shape: wrap a caller allocation as a
7777 // Mem tensor and verify the tensor reads the exact same bytes.
7778 let mut buf: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
7779 let ptr = buf.as_mut_ptr();
7780 let t = unsafe { Tensor::<f32>::from_foreign(ptr, &[2, 3], None, Some("test_foreign")) }
7781 .expect("valid from_foreign must succeed");
7782 assert_eq!(t.shape(), &[2, 3]);
7783 assert_eq!(t.memory(), TensorMemory::Mem);
7784 assert_eq!(t.name(), "test_foreign");
7785 let m = t.map().unwrap();
7786 // The tensor is a zero-copy borrow — it sees the caller's data.
7787 assert_eq!(m.as_slice(), &[1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]);
7788 }
7789
7790 #[test]
7791 fn from_foreign_write_visible_in_caller_allocation() {
7792 // Writes through the tensor's map land in the caller's buffer (zero-copy).
7793 let mut buf: Vec<u8> = vec![0u8; 6];
7794 let ptr = buf.as_mut_ptr();
7795 let t = unsafe { Tensor::<u8>::from_foreign(ptr, &[2, 3], None, None) }.unwrap();
7796 {
7797 let mut m = t.map().unwrap();
7798 m.as_mut_slice().copy_from_slice(&[10, 20, 30, 40, 50, 60]);
7799 }
7800 drop(t);
7801 // Mutations are visible in the original Vec — same physical buffer.
7802 assert_eq!(buf, vec![10, 20, 30, 40, 50, 60]);
7803 }
7804
7805 #[test]
7806 fn from_foreign_rejects_null_ptr() {
7807 let err = unsafe { Tensor::<u8>::from_foreign(std::ptr::null_mut(), &[4], None, None) }
7808 .unwrap_err();
7809 assert!(
7810 matches!(err, Error::InvalidArgument(ref m) if m.contains("non-null")),
7811 "expected InvalidArgument(non-null), got {err:?}"
7812 );
7813 }
7814
7815 #[test]
7816 fn from_foreign_rejects_empty_shape() {
7817 let mut dummy: u8 = 0;
7818 let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &[], None, None) }.unwrap_err();
7819 assert!(
7820 matches!(err, Error::InvalidSize(0)),
7821 "expected InvalidSize(0) for empty shape, got {err:?}"
7822 );
7823 }
7824
7825 #[test]
7826 fn from_foreign_rejects_overflow_shape() {
7827 // Two dimensions whose product overflows usize — the overflow guard must
7828 // fire before any pointer arithmetic is attempted.
7829 let mut dummy: u8 = 0;
7830 let huge = [usize::MAX / 2 + 1, 2];
7831 let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &huge, None, None) }.unwrap_err();
7832 assert!(
7833 matches!(err, Error::InvalidArgument(ref m) if m.contains("overflow")),
7834 "expected InvalidArgument(overflow), got {err:?}"
7835 );
7836 }
7837
7838 #[test]
7839 fn from_foreign_owner_keeps_allocation_alive() {
7840 // When `owner` is `Some`, dropping the Tensor must not free the backing
7841 // before the owner is also gone — the owner's Drop fires on last ref.
7842 use std::sync::atomic::{AtomicBool, Ordering};
7843 let flag = std::sync::Arc::new(AtomicBool::new(false));
7844 let flag2 = flag.clone();
7845 struct Guard(std::sync::Arc<AtomicBool>);
7846 impl Drop for Guard {
7847 fn drop(&mut self) {
7848 self.0.store(true, Ordering::SeqCst);
7849 }
7850 }
7851 let mut buf: Vec<u32> = vec![42u32; 4];
7852 let ptr = buf.as_mut_ptr();
7853 let owner: ForeignOwner = Box::new(Guard(flag2));
7854 let t = unsafe { Tensor::<u32>::from_foreign(ptr, &[4], Some(owner), None) }.unwrap();
7855 // Map co-owns the backing Arc; the owner must stay alive while the map lives.
7856 let m = t.map().unwrap();
7857 assert_eq!(m.as_slice()[0], 42);
7858 drop(t); // tensor dropped while map is still live
7859 assert!(
7860 !flag.load(Ordering::SeqCst),
7861 "owner must not drop while a map shares the backing"
7862 );
7863 drop(m);
7864 assert!(
7865 flag.load(Ordering::SeqCst),
7866 "owner Drop must fire when the last Arc reference is released"
7867 );
7868 }
7869}