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 DXGI-format math), same reasoning
105// as `iosurface_layout` below: compiled on every platform so it is
106// unit-tested on every CI lane. The allocator that consumes it
107// (`crate::d3d11`) is Windows-only.
108#[cfg(target_os = "windows")]
109pub mod d3d11;
110pub mod d3d11_layout;
111// Layout table is backend-agnostic (pure FourCC math). Storage
112// (`IoSurfaceTensor`) is `static`-only; `dynamic` reads IOSurface handles
113// through `ef_tensor_*`.
114#[cfg(any(target_os = "macos", target_os = "ios"))]
115mod iosurface_layout;
116// `static`-only, same reasoning as `ahardwarebuffer` above: reached only
117// through `TensorStorage`'s `IoSurface` variant.
118#[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "static"))]
119mod iosurface;
120pub mod lens;
121// `static`-only: `MemTensor` backs `TensorStorage::Mem`, `impl<T> Tensor<T>`
122// only. `dynamic`'s host-memory allocation goes through `ef_tensor_new`/
123// `ef_tensor_builder_alloc` in `libedgefirst_tensor.so` instead -- there is
124// no local `MemTensor` under `dynamic` to be dead code, so the whole module
125// gates rather than an internal `#[allow]`.
126#[cfg(feature = "static")]
127mod mem;
128mod pbo;
129pub mod pin;
130pub mod protocol;
131// `static`-only, same reasoning as `mem` above: `ShmTensor` backs
132// `TensorStorage::Shm`, `impl<T> Tensor<T>` only.
133#[cfg(all(unix, feature = "static"))]
134mod shm;
135mod tensor_dyn;
136#[cfg(test)]
137mod test_support;
138pub mod view;
139mod vocabulary;
140pub use colorimetry::{
141 ColorEncoding, ColorRange, ColorSpace, ColorTransfer, Colorimetry, MatrixWeights, RangeScaling,
142};
143
144/// Retained constructor: installs the coverage flush-on-abort handler for this
145/// crate's instrumented test binary. See `covguard`. Only present under
146/// coverage on Linux (`.init_array` is ELF-only; the i.MX flush is Linux-only).
147#[cfg(all(coverage, target_os = "linux"))]
148#[used]
149#[link_section = ".init_array"]
150static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
151 extern "C" fn ctor() {
152 crate::covguard::install();
153 }
154 ctor
155};
156
157// Backing tensor/map types are internal implementation details: callers
158// allocate `Tensor<T>` / `TensorDyn` and map them, never naming the per-memory
159// backing types directly. They are `pub(crate)` so they stay nameable for the
160// `TensorStorage` / `TensorMap` enums without leaking into the public API.
161// Exceptions kept public: `Pbo*` is a GL extension point implemented by the
162// image crate, and `image_iosurface_layout` is a public helper.
163#[cfg(all(target_os = "android", feature = "static"))]
164pub use crate::ahardwarebuffer::image_ahardwarebuffer_layout;
165#[cfg(all(target_os = "android", feature = "static"))]
166pub(crate) use crate::ahardwarebuffer::AHardwareBufferTensor;
167#[cfg(all(target_os = "linux", feature = "static"))]
168pub(crate) use crate::dma::DmaTensor;
169#[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "static"))]
170pub(crate) use crate::iosurface::IoSurfaceTensor;
171// The two geometry helpers are compiled for both backends: three of the four
172// surfaces that read a raw texture's geometry (the Python bindings, and the
173// protocol and blob imports) link `dynamic`, and only the C ABI links
174// `static`. See `crate::d3d11::geometry`.
175#[cfg(target_os = "windows")]
176pub use crate::d3d11::geometry::{d3d11_shared_handle_geometry, d3d11_texture_geometry};
177#[cfg(any(target_os = "macos", target_os = "ios"))]
178pub use crate::iosurface_layout::image_iosurface_layout;
179#[cfg(feature = "static")]
180pub(crate) use crate::mem::MemTensor;
181pub use crate::pbo::{
182 client_state_pbo_ops, read_pbo_vtable_parts, EfClientState, EfPboMapFn, EfPboUnmapFn,
183 PboClientParts, PboMapping, PboOps, PboOpsVtableParts, PboTensor,
184};
185#[cfg(all(unix, feature = "static"))]
186pub(crate) use crate::shm::ShmTensor;
187pub use cuda::{
188 client_state_cuda_ops, gl_map_resource, gl_register_buffer, gl_unmap_resource,
189 gl_unregister_resource, is_cuda_available, memcpy_device_to_host, memcpy_host_to_device,
190 runtime_path as cuda_runtime_path, stream_create, stream_destroy, stream_synchronize,
191 CudaGlOps, CudaHandle, CudaMap, CudaStream,
192};
193pub use detect::{unletter_norm, BoundingBox, DetectBox, ProtoData, ProtoLayout, Segmentation};
194pub use error::{Error, Result};
195// Static-backend-implementation code: backs `tensor-capi`'s own
196// `ef_tensor_export`/`ef_tensor_import` (the static cdylib's `TensorDyn`
197// serialization), the one caller in the whole workspace. It calls
198// `TensorDyn::new`/`from_fd` and reads fields (`.quantization()`,
199// `.colorimetry()`, `.pin_host()`, ...) that only the `static` backend's
200// richer `TensorDyn` surface has, so it never compiled under `dynamic` in
201// fact even though nothing gated it -- see `PRIMITIVE-INVENTORY.md`'s
202// "structural findings".
203#[cfg(feature = "static")]
204pub mod blob;
205#[cfg(feature = "static")]
206pub use blob::{
207 BlobError, BlobHeader, BlobPlane, BlobStrings, BlobView, Regions, TransportMode, HEADER_LEN,
208 PLANE_RECORD_LEN,
209};
210pub use format::{ChromaLayout, PixelFormat, PixelLayout, PlaneGeometry};
211pub use lens::Element;
212use num_traits::Num;
213pub use pin::HostPin;
214pub use protocol::{
215 dtype as tensor_dtype, dtype_to_dtype, format as tensor_format, format_from_code,
216 kind as tensor_kind, SendPtr, TensorDesc, ABI_VERSION,
217};
218use serde::{Deserialize, Serialize};
219#[cfg(unix)]
220use std::os::fd::OwnedFd;
221use std::{
222 fmt,
223 sync::{
224 atomic::{AtomicU64, Ordering},
225 Arc, Weak,
226 },
227};
228// `static`'s `Tensor<T>` is declared directly above, in this file; there is
229// nothing to re-export for it. `dynamic`'s lives in `tensor_dyn` (see that
230// module's docs) and is re-exported here under the same crate-root name so
231// both backends present the same `edgefirst_tensor::Tensor` to callers.
232#[cfg(feature = "dynamic")]
233pub use tensor_dyn::Tensor;
234pub use tensor_dyn::{Raw, TensorDyn};
235pub use view::HostView;
236pub use vocabulary::vocabulary_demo;
237
238/// Opaque keep-alive handle for a foreign-memory tensor (see
239/// [`Tensor::from_foreign`] / [`TensorDyn::from_foreign_ptr`]).
240///
241/// The HAL borrows the foreign buffer without owning it; this handle co-owns
242/// the *source* so the borrowed memory stays valid for the tensor's life. Its
243/// `Drop` releases the source — e.g. a small struct that calls `cudaFreeHost`,
244/// or a `Py<PyAny>` that decrements a NumPy array's refcount. Wrapping it in an
245/// `Arc` (then boxing each clone) makes the release fire exactly once, after
246/// the last sharing tensor/view/map drops, regardless of drop order.
247pub type ForeignOwner = Box<dyn std::any::Any + Send + Sync>;
248
249/// Re-export of `half::f16` so downstream crates can write
250/// `Tensor::<edgefirst_tensor::f16>::from_iosurface(…)` without
251/// adding `half` to their own dependency list. The version stays in
252/// lockstep with the `half` workspace dep.
253pub use half::f16;
254
255// =============================================================================
256// RGBA16F packed-layout geometry — single source of truth
257//
258// A `PlanarRgb` [3,H,W] or `PlanarRgba` [4,H,W] f16 tensor is represented
259// on the GPU as an RGBA16F surface (the only float format accepted by the
260// ANGLE IOSurface extension). Four contiguous f16 elements are packed into
261// each 8-byte RGBA16F texel, yielding a `(W/4, C*H)` surface.
262//
263// All call sites that need these dimensions must use `packed_rgba16f_layout`
264// so the rule lives in exactly one place. Currently consumed by:
265// - `crates/tensor/src/iosurface.rs` `new_image` (macOS IOSurface alloc)
266// - `crates/image/src/gl/iosurface_import.rs` (macOS GL IOSurface import)
267// - `crates/image/src/gl/processor/float.rs` (Linux GL float render — PBO
268// readback and DMA-BUF, also via the `dma_f16_packed_layout` wrapper)
269// =============================================================================
270
271/// Geometry of the RGBA16F-packed surface backing a planar F16 image tensor.
272///
273/// ANGLE only supports one float `(type, internal_format)` pair for IOSurface
274/// import: `(GL_HALF_FLOAT, GL_RGBA)` = RGBA16F (8 bytes/texel). To map a
275/// `[C, H, W]` f16 planar tensor onto such a surface, 4 contiguous f16
276/// elements are packed into each RGBA16F texel, yielding a surface of
277/// `(W/4, C*H)` texels at 8 bytes/texel. The byte stream is identical to a
278/// (nonexistent) R16F `(W, C*H)` surface and can be consumed as `&[f16]`
279/// with shape `[1, C, H, W]` without rearrangement.
280///
281/// Obtain via [`packed_rgba16f_layout`] — never construct directly.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct PackedRgba16fLayout {
284 /// Surface width in texels (`width / 4`).
285 pub surface_w: usize,
286 /// Surface height in texels (`planes * height`).
287 pub surface_h: usize,
288 /// Bytes per RGBA16F texel (always 8).
289 pub bytes_per_texel: usize,
290 /// Row pitch in bytes (`surface_w * 8`).
291 pub pitch: usize,
292}
293
294/// Canonical geometry for the RGBA16F-packed surface backing a planar F16
295/// image tensor.
296///
297/// Returns `Some(layout)` only when **all** of the following hold:
298///
299/// - `dtype == DType::F16`
300/// - `format` is `PixelFormat::PlanarRgb` (3 planes) or
301/// `PixelFormat::PlanarRgba` (4 planes)
302/// - `width % 4 == 0`
303///
304/// Returns `None` for any other `(format, dtype)` combination, misaligned
305/// width, or when the surface geometry would overflow `usize` — callers
306/// must fall back to a non-packed path or return a context-appropriate
307/// error.
308///
309/// # Examples
310///
311/// ```rust
312/// use edgefirst_tensor::{packed_rgba16f_layout, PixelFormat, DType};
313///
314/// let layout = packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 480).unwrap();
315/// assert_eq!(layout.surface_w, 160);
316/// assert_eq!(layout.surface_h, 1440);
317/// assert_eq!(layout.bytes_per_texel, 8);
318/// assert_eq!(layout.pitch, 1280);
319/// ```
320pub fn packed_rgba16f_layout(
321 format: PixelFormat,
322 dtype: DType,
323 width: usize,
324 height: usize,
325) -> Option<PackedRgba16fLayout> {
326 if dtype != DType::F16 {
327 return None;
328 }
329 let planes: usize = match format {
330 PixelFormat::PlanarRgb => 3,
331 PixelFormat::PlanarRgba => 4,
332 _ => return None,
333 };
334 if !width.is_multiple_of(4) {
335 return None;
336 }
337 let surface_w = width / 4;
338 // Checked arithmetic: a degenerate (height, width) could otherwise wrap
339 // and yield an under-sized layout, which downstream allocators trust for
340 // GPU/CPU buffer sizing. Overflow → None (handled like any other
341 // unsupported geometry).
342 let surface_h = planes.checked_mul(height)?;
343 let bytes_per_texel = 8;
344 let pitch = surface_w.checked_mul(bytes_per_texel)?;
345 Some(PackedRgba16fLayout {
346 surface_w,
347 surface_h,
348 bytes_per_texel,
349 pitch,
350 })
351}
352
353/// Geometry of the RGBA8888-packed surface backing a packed RGB u8/i8 image
354/// tensor.
355///
356/// GPUs have no 3-channel renderable format, so the GL engine's two-pass
357/// packed-RGB shader writes the tight `[H, W, 3]` byte stream into an
358/// RGBA8888 surface: each texel carries 4 consecutive RGB bytes, giving a
359/// `(W*3/4, H)` surface at 4 bytes/texel whose rows are byte-identical to
360/// tight RGB — consumable flat as `[H, W, 3]` with no rearrangement (the
361/// u8/i8 analog of [`packed_rgba16f_layout`]; i8 shares the layout since
362/// INT8 quantization is a per-byte `^0x80` bias, not a format change).
363///
364/// Returns `Some(layout)` only when `width % 4 == 0` (so `W*3` bytes divide
365/// into whole texels) and the geometry does not overflow `usize`.
366///
367/// # Examples
368///
369/// ```rust
370/// use edgefirst_tensor::packed_rgb888_layout;
371///
372/// let layout = packed_rgb888_layout(640, 480).unwrap();
373/// assert_eq!(layout.surface_w, 480); // 640*3/4
374/// assert_eq!(layout.surface_h, 480);
375/// assert_eq!(layout.bytes_per_texel, 4);
376/// assert_eq!(layout.pitch, 1920); // 640*3
377/// assert!(packed_rgb888_layout(641, 480).is_none());
378/// ```
379pub fn packed_rgb888_layout(width: usize, height: usize) -> Option<PackedRgb888Layout> {
380 if !width.is_multiple_of(4) {
381 return None;
382 }
383 let row_bytes = width.checked_mul(3)?;
384 let surface_w = row_bytes / 4;
385 Some(PackedRgb888Layout {
386 surface_w,
387 surface_h: height,
388 bytes_per_texel: 4,
389 pitch: row_bytes,
390 })
391}
392
393/// Geometry of the RGBA8888 surface backing a packed RGB u8/i8 image tensor.
394///
395/// Obtain via [`packed_rgb888_layout`] — never construct directly.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub struct PackedRgb888Layout {
398 /// Surface width in texels (`width * 3 / 4`).
399 pub surface_w: usize,
400 /// Surface height in texels (`height`).
401 pub surface_h: usize,
402 /// Bytes per RGBA8888 texel (always 4).
403 pub bytes_per_texel: usize,
404 /// Row pitch in bytes (`surface_w * 4` = `width * 3`).
405 pub pitch: usize,
406}
407
408/// The logical `(width, height)` an **allocation** shape describes for
409/// `format` -- the inverse of [`PixelFormat::allocation_shape`].
410///
411/// Used wherever a tensor arrives carrying a shape and a format but not its
412/// dimensions: the C constructors, the Python bindings and the cross-package
413/// protocol import.
414///
415/// # The input is the allocation shape, not the addressing shape
416///
417/// [`PixelFormat::allocation_shape`] is what [`Tensor::image`] allocates from
418/// and what `descriptor_pinned` emits from `shape()`, so that is what this
419/// inverts. [`PixelFormat::addressing_shape`] is a different, smaller grid,
420/// and for a semi-planar format the two collide: NV12's addressing shape is
421/// `[h, w]` and its allocation shape is `[combined_h, w]`, both rank 2. Handed
422/// an addressing shape this cannot tell -- NV12 `[480, 640]` reads as a
423/// combined height of 480 and answers `(640, 320)`, silently. Nothing in the
424/// shape distinguishes them, so callers must pass the allocation shape.
425///
426/// A D3D11 import is the one place this cannot bite: the constructors check
427/// the derived size against the texture description, so a mismatch fails with
428/// [`Error::InvalidArgument`] naming both. On Windows, prefer
429/// `d3d11_texture_geometry` and `d3d11_shared_handle_geometry`, which read the
430/// dimensions off the texture itself and leave the shape as a consistency
431/// check -- except for a semi-planar texture, whose width is its row stride
432/// rather than the image width, so those helpers take the width from the shape
433/// they are given and refuse to answer without one.
434///
435/// The rank is part of the format's contract, so a shape of the wrong rank is
436/// an error rather than an interpretation of whatever dimensions are present:
437///
438/// | Layout | Shape | Result |
439/// |--------|-------|--------|
440/// | Packed | `[h, w, c]` | `(shape[1], shape[0])` |
441/// | Planar | `[c, h, w]` | `(shape[2], shape[1])` |
442/// | SemiPlanar | `[combined_h, w]` | `(shape[1], image height)` |
443///
444/// A semi-planar shape carries the *combined* plane height, so the logical
445/// height is recovered through
446/// [`PixelFormat::image_height_from_combined`], which rejects a combined
447/// height no image height produces (721 for NV12, say) instead of rounding to
448/// a nearby one.
449///
450/// # Errors
451///
452/// [`Error::InvalidShape`] for a shape of the wrong rank, or a semi-planar
453/// combined height with no image height behind it.
454///
455/// # Example
456///
457/// ```
458/// use edgefirst_tensor::{image_dims_from_shape, PixelFormat};
459/// assert_eq!(image_dims_from_shape(PixelFormat::Rgba, &[480, 640, 4]).unwrap(), (640, 480));
460/// assert_eq!(image_dims_from_shape(PixelFormat::PlanarRgb, &[3, 480, 640]).unwrap(), (640, 480));
461/// assert_eq!(image_dims_from_shape(PixelFormat::Nv12, &[722, 640]).unwrap(), (640, 481));
462/// assert!(image_dims_from_shape(PixelFormat::Nv12, &[721, 640]).is_err());
463/// ```
464pub fn image_dims_from_shape(format: PixelFormat, shape: &[usize]) -> Result<(usize, usize)> {
465 let wrong_rank = |expected: usize| {
466 Error::InvalidShape(format!(
467 "image_dims_from_shape: {format:?} describes a rank-{expected} allocation \
468 shape, got rank {} ({shape:?})",
469 shape.len()
470 ))
471 };
472 match format.layout() {
473 PixelLayout::Packed => {
474 if shape.len() != 3 {
475 return Err(wrong_rank(3));
476 }
477 Ok((shape[1], shape[0]))
478 }
479 PixelLayout::Planar => {
480 if shape.len() != 3 {
481 return Err(wrong_rank(3));
482 }
483 Ok((shape[2], shape[1]))
484 }
485 PixelLayout::SemiPlanar => {
486 if shape.len() != 2 {
487 return Err(wrong_rank(2));
488 }
489 let height = format.image_height_from_combined(shape[0]).ok_or_else(|| {
490 Error::InvalidShape(format!(
491 "image_dims_from_shape: no {format:?} image height has combined plane \
492 height {} (shape {shape:?})",
493 shape[0]
494 ))
495 })?;
496 Ok((shape[1], height))
497 }
498 }
499}
500
501/// Per-plane DMA-BUF descriptor for external buffer import.
502///
503/// Owns a duplicated file descriptor plus optional stride and offset metadata.
504/// The fd is duplicated eagerly in [`new()`](Self::new) so that a bad fd is
505/// caught immediately. `import_image` consumes the descriptor and takes
506/// ownership of the duped fd — no further cleanup is needed by the caller.
507///
508/// # Examples
509///
510/// ```rust,no_run
511/// use edgefirst_tensor::PlaneDescriptor;
512/// use std::os::fd::BorrowedFd;
513///
514/// // SAFETY: fd 42 is hypothetical; real code must pass a valid fd.
515/// let pd = unsafe { PlaneDescriptor::new(BorrowedFd::borrow_raw(42)) }
516/// .unwrap()
517/// .with_stride(2048)
518/// .with_offset(0);
519/// ```
520#[cfg(unix)]
521pub struct PlaneDescriptor {
522 fd: OwnedFd,
523 stride: Option<usize>,
524 offset: Option<usize>,
525}
526
527#[cfg(unix)]
528impl PlaneDescriptor {
529 /// Create a new plane descriptor by duplicating the given file descriptor.
530 ///
531 /// The fd is duped immediately — a bad fd fails here rather than inside
532 /// `import_image`. The caller retains ownership of the original fd.
533 ///
534 /// # Errors
535 ///
536 /// Returns an error if the `dup()` syscall fails (e.g. invalid fd or
537 /// fd limit reached).
538 pub fn new(fd: std::os::fd::BorrowedFd<'_>) -> Result<Self> {
539 let owned = fd.try_clone_to_owned()?;
540 Ok(Self {
541 fd: owned,
542 stride: None,
543 offset: None,
544 })
545 }
546
547 /// Set the row stride in bytes (consuming builder).
548 pub fn with_stride(mut self, stride: usize) -> Self {
549 self.stride = Some(stride);
550 self
551 }
552
553 /// Set the plane offset in bytes (consuming builder).
554 pub fn with_offset(mut self, offset: usize) -> Self {
555 self.offset = Some(offset);
556 self
557 }
558
559 /// Consume the descriptor and return the owned file descriptor.
560 pub fn into_fd(self) -> OwnedFd {
561 self.fd
562 }
563
564 /// Row stride in bytes, if set.
565 pub fn stride(&self) -> Option<usize> {
566 self.stride
567 }
568
569 /// Plane offset in bytes, if set.
570 pub fn offset(&self) -> Option<usize> {
571 self.offset
572 }
573}
574
575/// A rectangular sub-region of a tensor's leading spatial frame, in pixels.
576///
577/// `Region` is the single rectangle type in the workspace: the argument to
578/// [`Tensor::view`], the source sampling window in the image crate's `Crop`,
579/// and the geometry the image backend lowers to a `glViewport` (a destination
580/// tile) or a sampling rectangle (a source). Coordinates are pixel/element
581/// units of the leading spatial axes; byte addressing is derived from the
582/// parent's row stride, not stored here.
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584pub struct Region {
585 pub x: usize,
586 pub y: usize,
587 pub width: usize,
588 pub height: usize,
589}
590
591impl Region {
592 /// Create a region at `(x, y)` spanning `width` × `height` pixels.
593 pub fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
594 Self {
595 x,
596 y,
597 width,
598 height,
599 }
600 }
601
602 /// True when the region lies fully within a `width` × `height` frame.
603 pub fn fits_within(&self, width: usize, height: usize) -> bool {
604 self.x.saturating_add(self.width) <= width && self.y.saturating_add(self.height) <= height
605 }
606}
607
608/// The parent image a [`view`](Tensor::view)/[`batch`](Tensor::batch) sub-region
609/// was carved from, snapshotted at the time the view was created.
610///
611/// A view shares the parent's `BufferIdentity` and addresses a sub-rectangle of
612/// it. The GL backend keys its EGLImage import on the **parent** geometry (so all
613/// sibling views of one buffer collapse to a single import) and renders each
614/// view as a `glViewport`+`glScissor` ROI at `(x, y, width, height)` within that
615/// parent — the view is render state, never a distinct import. `parent_width`/
616/// `parent_height` are the parent's logical pixel dimensions; `x`/`y` are this
617/// view's top-left origin within the parent (pixels). Nested views compose:
618/// the snapshot always names the **root** parent, with offsets accumulated.
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub struct ViewOrigin {
621 /// Logical width of the root parent image, in pixels.
622 pub parent_width: usize,
623 /// Logical height of the root parent image, in pixels. For a `batch(n)` view
624 /// of an `[N, H, W, C]` tensor this is `N * H` (the tiles stack vertically in
625 /// the shared buffer).
626 pub parent_height: usize,
627 /// The parent's row stride in **bytes**. The GL backend keys its EGLImage
628 /// import/cache and pitch on this — NOT on the view's own `row_stride`, which
629 /// a single-row view sets tight (for map-span safety). Using the parent
630 /// stride keeps the import pitch parent-consistent so single-row and
631 /// multi-row sibling views collapse onto the same parent import.
632 pub parent_row_stride: usize,
633 /// This view's top-left x origin within the root parent, in pixels.
634 pub x: usize,
635 /// This view's top-left y origin within the root parent, in pixels.
636 pub y: usize,
637}
638
639// Declared through `ef_vocabulary!` (see `vocabulary.rs`): `.code()` is this
640// crate's wire numbering, mirrored by `protocol::dtype` and the C ABI's
641// `hal_dtype`. Both surfaces are generated from the same per-variant literal
642// below rather than re-typed by hand, and `dtype_codes_are_unchanged_by_the_macro`
643// in `tests/vocabulary.rs` pins them against the values already shipping.
644crate::ef_vocabulary! {
645 /// Element type discriminant for runtime type identification.
646 #[derive(Serialize, Deserialize)]
647 #[non_exhaustive]
648 pub enum DType {
649 U8 = 0, "u8", U8,
650 I8 = 1, "i8", I8,
651 U16 = 2, "u16", U16,
652 I16 = 3, "i16", I16,
653 U32 = 4, "u32", U32,
654 I32 = 5, "i32", I32,
655 U64 = 6, "u64", U64,
656 I64 = 7, "i64", I64,
657 F16 = 8, "f16", F16,
658 F32 = 9, "f32", F32,
659 F64 = 10, "f64", F64,
660 }
661 // `#[doc(hidden)]`: this module has to be `pub` for `protocol::dtype`'s
662 // `pub use` re-export below to compile, but it is emission plumbing, not
663 // a second documented API for the same eleven codes -- `protocol::dtype`
664 // is the canonical, documented path.
665 #[doc(hidden)]
666 pub mod dtype_wire;
667}
668
669impl DType {
670 /// Size of one element in bytes.
671 pub const fn size(&self) -> usize {
672 match self {
673 Self::U8 | Self::I8 => 1,
674 Self::U16 | Self::I16 | Self::F16 => 2,
675 Self::U32 | Self::I32 | Self::F32 => 4,
676 Self::U64 | Self::I64 | Self::F64 => 8,
677 }
678 }
679
680 /// Short type name (e.g., "u8", "f32", "f16"). Identical to
681 /// [`DType::as_str`]; kept as its own method for existing callers.
682 pub fn name(&self) -> &'static str {
683 self.as_str()
684 }
685}
686
687impl fmt::Display for DType {
688 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
689 f.write_str(self.name())
690 }
691}
692
693/// Map a static numeric type `T` to its `DType` discriminant, returning
694/// `None` for types that do not have a `DType` representation (e.g.
695/// user-defined wrappers in tests).
696///
697/// Runtime dtype of a static `Tensor<T>` element type — a safe `TypeId`
698/// allowlist of HAL's primitive numeric types (`Some` for `u8`..`i64` /
699/// `f16` / `f32` / `f64`, `None` otherwise). Used by the macOS IOSurface
700/// image constructors (FourCC / pixel-format lookup) and by the `Mem`
701/// backing's `alloc_zeroed` fast path (these types' `T::zero()` is the
702/// all-zeros bit pattern), so it is compiled on every target -- but only
703/// under `static`: every call site lives in `impl<T> Tensor<T>`, itself
704/// `#[cfg(feature = "static")]`.
705#[cfg(feature = "static")]
706pub(crate) fn dtype_of<T: 'static>() -> Option<DType> {
707 use std::any::TypeId;
708 let id = TypeId::of::<T>();
709 if id == TypeId::of::<u8>() {
710 Some(DType::U8)
711 } else if id == TypeId::of::<i8>() {
712 Some(DType::I8)
713 } else if id == TypeId::of::<u16>() {
714 Some(DType::U16)
715 } else if id == TypeId::of::<i16>() {
716 Some(DType::I16)
717 } else if id == TypeId::of::<u32>() {
718 Some(DType::U32)
719 } else if id == TypeId::of::<i32>() {
720 Some(DType::I32)
721 } else if id == TypeId::of::<u64>() {
722 Some(DType::U64)
723 } else if id == TypeId::of::<i64>() {
724 Some(DType::I64)
725 } else if id == TypeId::of::<half::f16>() {
726 Some(DType::F16)
727 } else if id == TypeId::of::<f32>() {
728 Some(DType::F32)
729 } else if id == TypeId::of::<f64>() {
730 Some(DType::F64)
731 } else {
732 None
733 }
734}
735
736// =============================================================================
737// Quantization metadata — type-gated to integer element types via sealed
738// `IntegerType` trait. Accessors on `Tensor<T>` only compile when `T` is
739// an integer type; calling them on `Tensor<f32>` / `Tensor<f16>` etc. is a
740// compile error, not a runtime one.
741// =============================================================================
742
743mod sealed {
744 pub trait Sealed {}
745 impl Sealed for u8 {}
746 impl Sealed for i8 {}
747 impl Sealed for u16 {}
748 impl Sealed for i16 {}
749 impl Sealed for u32 {}
750 impl Sealed for i32 {}
751 impl Sealed for u64 {}
752 impl Sealed for i64 {}
753 // f16 / f32 / f64 are sealed too (crate::lens::Element covers all
754 // eleven element types), but deliberately NOT given `IntegerType` below.
755 impl Sealed for half::f16 {}
756 impl Sealed for f32 {}
757 impl Sealed for f64 {}
758}
759
760/// Integer element types that may carry quantization metadata.
761///
762/// Sealed trait: implemented for `u8`, `i8`, `u16`, `i16`, `u32`, `i32`,
763/// `u64`, `i64`. Cannot be implemented downstream. Float element types
764/// (`half::f16`, `f32`, `f64`) are explicitly excluded — quantization
765/// metadata does not apply to float tensors per the edgefirst.json spec.
766pub trait IntegerType: sealed::Sealed {}
767impl IntegerType for u8 {}
768impl IntegerType for i8 {}
769impl IntegerType for u16 {}
770impl IntegerType for i16 {}
771impl IntegerType for u32 {}
772impl IntegerType for i32 {}
773impl IntegerType for u64 {}
774impl IntegerType for i64 {}
775
776/// Quantization parameters for an integer tensor.
777///
778/// Covers all four modes the edgefirst.json spec defines:
779///
780/// | Mode | `scale.len()` | `zero_point` | `axis` |
781/// |---|---|---|---|
782/// | Per-tensor symmetric | 1 | `None` | `None` |
783/// | Per-tensor asymmetric | 1 | `Some(len == 1)` | `None` |
784/// | Per-channel symmetric | >1 | `None` | `Some(c)` |
785/// | Per-channel asymmetric | >1 | `Some(len == scale.len())` | `Some(c)` |
786///
787/// The quantized storage type is carried on the parent [`Tensor<T>`]; this
788/// struct does not duplicate it. Construct via the four named constructors
789/// (the only public entry points); direct field mutation is not allowed so
790/// invalid combinations cannot be represented.
791///
792/// Dequantization formula:
793///
794/// ```text
795/// real_value = scale[c] × (quantized_value[c] - zero_point[c])
796/// ```
797///
798/// where `c` is the channel index (always `0` for per-tensor).
799#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
800pub struct Quantization {
801 /// Per-tensor: `vec![scale]`. Per-channel: `vec![scale_0, scale_1, ...]`.
802 #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
803 scale: Vec<f32>,
804
805 /// `None` means symmetric (zero-point is 0). `Some(vec)` must have the
806 /// same length as `scale`.
807 #[serde(
808 default,
809 deserialize_with = "deserialize_opt_scalar_or_vec_i32",
810 skip_serializing_if = "Option::is_none"
811 )]
812 zero_point: Option<Vec<i32>>,
813
814 /// Channel axis for per-channel quantization. `Some(_)` iff
815 /// `scale.len() > 1`. Validated against the parent tensor's shape at
816 /// `set_quantization()` time.
817 #[serde(default, skip_serializing_if = "Option::is_none")]
818 axis: Option<usize>,
819}
820
821/// Semantic mode discriminant for hot-path kernel dispatch.
822///
823/// Obtain via [`Quantization::mode`] once at kernel entry; never inside a
824/// pixel-level loop. The enum is borrow-based so the hot kernel receives
825/// the scales / zero-points as slices without reallocation.
826#[derive(Debug, Clone, Copy)]
827pub enum QuantMode<'a> {
828 PerTensorSymmetric {
829 scale: f32,
830 },
831 PerTensor {
832 scale: f32,
833 zero_point: i32,
834 },
835 PerChannelSymmetric {
836 scales: &'a [f32],
837 axis: usize,
838 },
839 PerChannel {
840 scales: &'a [f32],
841 zero_points: &'a [i32],
842 axis: usize,
843 },
844}
845
846impl Quantization {
847 /// Per-tensor symmetric (zero_point = 0).
848 pub fn per_tensor_symmetric(scale: f32) -> Self {
849 Self {
850 scale: vec![scale],
851 zero_point: None,
852 axis: None,
853 }
854 }
855
856 /// Per-tensor asymmetric — the most common runtime shape.
857 pub fn per_tensor(scale: f32, zero_point: i32) -> Self {
858 Self {
859 scale: vec![scale],
860 zero_point: Some(vec![zero_point]),
861 axis: None,
862 }
863 }
864
865 /// Per-channel symmetric. Errors on empty `scales`.
866 pub fn per_channel_symmetric(scales: Vec<f32>, axis: usize) -> Result<Self> {
867 if scales.is_empty() {
868 return Err(Error::QuantizationInvalid {
869 field: "scale.len",
870 expected: "non-empty per-channel scales".to_string(),
871 got: "length 0".to_string(),
872 });
873 }
874 Ok(Self {
875 scale: scales,
876 zero_point: None,
877 axis: Some(axis),
878 })
879 }
880
881 /// Per-channel asymmetric. Errors on length mismatch between `scales`
882 /// and `zero_points`, or empty arrays.
883 pub fn per_channel(scales: Vec<f32>, zero_points: Vec<i32>, axis: usize) -> Result<Self> {
884 if scales.is_empty() {
885 return Err(Error::QuantizationInvalid {
886 field: "scale.len",
887 expected: "non-empty per-channel scales".to_string(),
888 got: "length 0".to_string(),
889 });
890 }
891 if scales.len() != zero_points.len() {
892 return Err(Error::QuantizationInvalid {
893 field: "zero_point.len",
894 expected: format!("length matches scale ({})", scales.len()),
895 got: format!("length {}", zero_points.len()),
896 });
897 }
898 Ok(Self {
899 scale: scales,
900 zero_point: Some(zero_points),
901 axis: Some(axis),
902 })
903 }
904
905 /// Borrow-based dispatch view. Match once at kernel entry.
906 pub fn mode(&self) -> QuantMode<'_> {
907 match (self.scale.len(), self.zero_point.as_deref(), self.axis) {
908 (1, None, _) => QuantMode::PerTensorSymmetric {
909 scale: self.scale[0],
910 },
911 (1, Some(zps), _) => QuantMode::PerTensor {
912 scale: self.scale[0],
913 zero_point: zps.first().copied().unwrap_or(0),
914 },
915 (_, None, Some(axis)) => QuantMode::PerChannelSymmetric {
916 scales: &self.scale,
917 axis,
918 },
919 (_, Some(zps), Some(axis)) => QuantMode::PerChannel {
920 scales: &self.scale,
921 zero_points: zps,
922 axis,
923 },
924 // The `validate()` path prevents constructing a
925 // per-channel Quantization without an axis, so the remaining
926 // pattern is unreachable in practice. Fall back to
927 // per-tensor symmetric using scale[0] to avoid panicking in
928 // release; debug builds assert.
929 _ => {
930 debug_assert!(
931 false,
932 "Quantization::mode: per-channel without axis is unreachable"
933 );
934 QuantMode::PerTensorSymmetric {
935 scale: self.scale.first().copied().unwrap_or(1.0),
936 }
937 }
938 }
939 }
940
941 /// Returns `true` for per-tensor quantization (`scale.len() == 1`).
942 pub fn is_per_tensor(&self) -> bool {
943 self.scale.len() == 1
944 }
945
946 /// Returns `true` for per-channel quantization (`scale.len() > 1`).
947 pub fn is_per_channel(&self) -> bool {
948 self.scale.len() > 1
949 }
950
951 /// Returns `true` for symmetric quantization (no zero-point, or
952 /// zero-point vector of all zeros).
953 pub fn is_symmetric(&self) -> bool {
954 match &self.zero_point {
955 None => true,
956 Some(zps) => zps.iter().all(|&z| z == 0),
957 }
958 }
959
960 /// Borrow the scale array. Length 1 for per-tensor; `num_channels` for
961 /// per-channel.
962 pub fn scale(&self) -> &[f32] {
963 &self.scale
964 }
965
966 /// Borrow the zero-point array. `None` for symmetric.
967 pub fn zero_point(&self) -> Option<&[i32]> {
968 self.zero_point.as_deref()
969 }
970
971 /// Channel axis for per-channel quantization. `None` for per-tensor.
972 pub fn axis(&self) -> Option<usize> {
973 self.axis
974 }
975
976 /// Validate against a target tensor shape. Runs in
977 /// `Tensor::set_quantization()`. Catches:
978 /// - empty `scale` (reject — must declare at least one factor)
979 /// - `zero_point` length inconsistent with `scale` (reject —
980 /// per-tensor must have len 1, per-channel must match `scale.len`)
981 /// - `axis >= shape.len()` (axis out of range)
982 /// - `scale.len() != shape[axis]` for per-channel
983 /// - per-channel without axis (reject)
984 /// - per-tensor with redundant axis (reject)
985 ///
986 /// `static`-only: `Tensor::set_quantization` is `impl<T> Tensor<T>`,
987 /// itself `#[cfg(feature = "static")]`. `dynamic`'s
988 /// `TensorDyn::set_quantization` (`dynamic_backend.rs`) sends the raw
989 /// scale/zero-point/axis over FFI and lets `libedgefirst_tensor.so`
990 /// validate them on its own (`static`) side instead of calling this
991 /// method locally.
992 #[cfg(feature = "static")]
993 pub(crate) fn validate(&self, shape: &[usize]) -> Result<()> {
994 // `Quantization` is `Deserialize`, so malformed JSON like
995 // `{"scale": [], "zero_point": []}` could otherwise produce an
996 // ill-defined value that confuses `mode()` selection and the
997 // per-channel kernels' indexing.
998 if self.scale.is_empty() {
999 return Err(Error::QuantizationInvalid {
1000 field: "scale.len",
1001 expected: ">= 1".to_string(),
1002 got: "0".to_string(),
1003 });
1004 }
1005 if let Some(zps) = self.zero_point.as_ref() {
1006 // Per-tensor: scale.len() == 1 and zero_point.len() must == 1.
1007 // Per-channel: zero_point.len() must == scale.len().
1008 let expected = if self.scale.len() == 1 {
1009 1
1010 } else {
1011 self.scale.len()
1012 };
1013 if zps.len() != expected {
1014 return Err(Error::QuantizationInvalid {
1015 field: "zero_point.len",
1016 expected: format!(
1017 "{expected} (matching {})",
1018 if self.scale.len() == 1 {
1019 "per-tensor scale"
1020 } else {
1021 "per-channel scale.len"
1022 }
1023 ),
1024 got: format!("length {}", zps.len()),
1025 });
1026 }
1027 }
1028
1029 match (self.scale.len(), self.axis) {
1030 (1, None) => Ok(()),
1031 (1, Some(_)) => Err(Error::QuantizationInvalid {
1032 field: "per_tensor_redundant_axis",
1033 expected: "axis=None for per-tensor quantization".to_string(),
1034 got: format!("axis={:?}", self.axis),
1035 }),
1036 (_, None) => Err(Error::QuantizationInvalid {
1037 field: "per_channel_requires_axis",
1038 expected: format!(
1039 "axis=Some(_) for per-channel quantization (scale.len={})",
1040 self.scale.len()
1041 ),
1042 got: "axis=None".to_string(),
1043 }),
1044 (n, Some(axis)) => {
1045 if axis >= shape.len() {
1046 return Err(Error::QuantizationInvalid {
1047 field: "axis",
1048 expected: format!("axis < tensor rank ({})", shape.len()),
1049 got: format!("axis={axis}"),
1050 });
1051 }
1052 if shape[axis] != n {
1053 return Err(Error::QuantizationInvalid {
1054 field: "scale.len",
1055 expected: format!("length matches shape[{axis}] ({})", shape[axis]),
1056 got: format!("length {n}"),
1057 });
1058 }
1059 Ok(())
1060 }
1061 }
1062 }
1063}
1064
1065impl From<(f32, i32)> for Quantization {
1066 /// Convenience construction from a `(scale, zero_point)` tuple. Matches
1067 /// the legacy `QuantTuple` / `Quantization::new` calling convention so
1068 /// existing `(0.1, -128).into()` sites keep working.
1069 fn from((scale, zero_point): (f32, i32)) -> Self {
1070 Self::per_tensor(scale, zero_point)
1071 }
1072}
1073
1074fn deserialize_scalar_or_vec_f32<'de, D: serde::Deserializer<'de>>(
1075 de: D,
1076) -> std::result::Result<Vec<f32>, D::Error> {
1077 use serde::de::{self, Visitor};
1078 struct V;
1079 impl<'de> Visitor<'de> for V {
1080 type Value = Vec<f32>;
1081 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1082 f.write_str("f32 or array of f32")
1083 }
1084 fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
1085 Ok(vec![v as f32])
1086 }
1087 #[allow(clippy::cast_possible_truncation)]
1088 fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1089 Ok(vec![v as f32])
1090 }
1091 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1092 fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1093 Ok(vec![v as f32])
1094 }
1095 fn visit_seq<A: de::SeqAccess<'de>>(
1096 self,
1097 mut seq: A,
1098 ) -> std::result::Result<Self::Value, A::Error> {
1099 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1100 while let Some(x) = seq.next_element::<f32>()? {
1101 out.push(x);
1102 }
1103 Ok(out)
1104 }
1105 }
1106 de.deserialize_any(V)
1107}
1108
1109fn deserialize_opt_scalar_or_vec_i32<'de, D: serde::Deserializer<'de>>(
1110 de: D,
1111) -> std::result::Result<Option<Vec<i32>>, D::Error> {
1112 use serde::de::{self, Visitor};
1113 struct V;
1114 impl<'de> Visitor<'de> for V {
1115 type Value = Option<Vec<i32>>;
1116 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1117 f.write_str("null, i32, or array of i32")
1118 }
1119 fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1120 Ok(None)
1121 }
1122 fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1123 Ok(None)
1124 }
1125 fn visit_some<D2: serde::Deserializer<'de>>(
1126 self,
1127 de: D2,
1128 ) -> std::result::Result<Self::Value, D2::Error> {
1129 struct Inner;
1130 impl<'de> Visitor<'de> for Inner {
1131 type Value = Vec<i32>;
1132 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1133 f.write_str("i32 or array of i32")
1134 }
1135 #[allow(clippy::cast_possible_truncation)]
1136 fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1137 Ok(vec![v as i32])
1138 }
1139 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1140 fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1141 Ok(vec![v as i32])
1142 }
1143 fn visit_seq<A: de::SeqAccess<'de>>(
1144 self,
1145 mut seq: A,
1146 ) -> std::result::Result<Self::Value, A::Error> {
1147 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1148 while let Some(x) = seq.next_element::<i32>()? {
1149 out.push(x);
1150 }
1151 Ok(out)
1152 }
1153 }
1154 de.deserialize_any(Inner).map(Some)
1155 }
1156 #[allow(clippy::cast_possible_truncation)]
1157 fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1158 Ok(Some(vec![v as i32]))
1159 }
1160 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1161 fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1162 Ok(Some(vec![v as i32]))
1163 }
1164 fn visit_seq<A: de::SeqAccess<'de>>(
1165 self,
1166 mut seq: A,
1167 ) -> std::result::Result<Self::Value, A::Error> {
1168 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1169 while let Some(x) = seq.next_element::<i32>()? {
1170 out.push(x);
1171 }
1172 Ok(Some(out))
1173 }
1174 }
1175 de.deserialize_option(V)
1176}
1177
1178/// Count of tensor maps requested beyond the buffer's declared
1179/// [`CpuAccess`] (including any CPU map of a `CpuAccess::None` buffer).
1180/// Undeclared CPU access is a pipeline smell: it forfeits layout
1181/// optimizations (write-combined mappings, tile compression) and may be
1182/// slow or refused on Android. Read via [`unplanned_cpu_access_count`];
1183/// each offending buffer also logs one warning.
1184static UNPLANNED_CPU_ACCESS: AtomicU64 = AtomicU64::new(0);
1185
1186/// Number of tensor maps that exceeded the buffer's declared
1187/// [`CpuAccess`] since process start. A pipeline that declares its CPU
1188/// access correctly holds this flat; see [`CpuAccess`] for the contract.
1189pub fn unplanned_cpu_access_count() -> u64 {
1190 UNPLANNED_CPU_ACCESS.load(Ordering::Relaxed)
1191}
1192
1193/// Record an unplanned CPU access on `identity_id`, warning once per
1194/// buffer (a steady-state pipeline maps the same buffer every frame — a
1195/// per-map warn would flood the log; repeats count silently).
1196///
1197/// `static`-only: the sole caller is `impl<T> TensorMapTrait<T> for
1198/// Tensor<T>` in `lib.rs`, itself `#[cfg(feature = "static")]`.
1199/// [`unplanned_cpu_access_count`] stays available under `dynamic` too --
1200/// it just always reads 0 there, honestly reflecting that this backend
1201/// produces no such telemetry.
1202#[cfg(feature = "static")]
1203pub(crate) fn note_unplanned_cpu_access(identity_id: u64, backend: &str, detail: &str) {
1204 UNPLANNED_CPU_ACCESS.fetch_add(1, Ordering::Relaxed);
1205 static WARNED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<u64>>> =
1206 std::sync::OnceLock::new();
1207 let warned = WARNED.get_or_init(Default::default);
1208 if warned.lock().is_ok_and(|mut s| s.insert(identity_id)) {
1209 log::warn!(
1210 "unplanned CPU access on {backend} buffer (identity {identity_id}): {detail} — \
1211 declare the intent at allocation (CpuAccess::Read/Write/ReadWrite) to make \
1212 this a planned, optimized mapping"
1213 );
1214 }
1215}
1216
1217/// Uniform guard for mutable access through a read-only map. Every
1218/// `TensorMap` backend calls this from `as_mut_slice`/`deref_mut` so a
1219/// `map_read()` misuse fails identically on all platforms instead of
1220/// passing on tolerant ones and exploding only on Android.
1221#[inline]
1222pub(crate) fn assert_map_writable(writable: bool, backend: &str) {
1223 assert!(
1224 writable,
1225 "{backend} map is read-only (obtained via map_read()/CpuAccess::Read) — \
1226 use map_mut() or map_write() for mutable access"
1227 );
1228}
1229
1230/// Declared CPU involvement for an image tensor, chosen at allocation.
1231///
1232/// The HAL assumes buffers are produced and consumed by hardware (ISP,
1233/// codec, GPU, NPU) — hardware access needs no declaration. CPU access is
1234/// the opt-in: it selects the CPU usage/mapping mode at allocation
1235/// (write-combined for `Write`, cached for `Read`) and, on Android, pins
1236/// the layout linear (vendor tile compression requires `None`).
1237///
1238/// Mapping beyond the declared access is best-effort, never silent: it
1239/// may be refused ([`Error::NotImplemented`]) or take a slow path, and it
1240/// always increments [`unplanned_cpu_access_count`] with a once-per-buffer
1241/// warning.
1242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1243pub enum CpuAccess {
1244 /// Hardware-only buffer (the default): no CPU mapping declared.
1245 /// Compression-eligible on platforms with vendor tile layouts.
1246 #[default]
1247 None,
1248 /// CPU reads (verification, CPU consumers) — cached mapping.
1249 Read,
1250 /// CPU writes (decode targets) — write-combined mapping where the
1251 /// platform supports it; reading through a `Write` map is undeclared.
1252 Write,
1253 /// CPU reads and writes — the pre-CpuAccess implicit behavior.
1254 ReadWrite,
1255}
1256
1257impl CpuAccess {
1258 /// Whether this declaration includes CPU reads.
1259 pub fn reads(self) -> bool {
1260 matches!(self, CpuAccess::Read | CpuAccess::ReadWrite)
1261 }
1262
1263 /// Whether this declaration includes CPU writes.
1264 pub fn writes(self) -> bool {
1265 matches!(self, CpuAccess::Write | CpuAccess::ReadWrite)
1266 }
1267
1268 /// Whether this declaration covers `requested` (every direction the
1269 /// request needs is declared).
1270 pub fn covers(self, requested: CpuAccess) -> bool {
1271 (!requested.reads() || self.reads()) && (!requested.writes() || self.writes())
1272 }
1273}
1274
1275/// Requested tile-compression behavior for an image allocation (set via
1276/// [`ImageDesc::with_compression`]).
1277///
1278/// Vendor GPUs store textures in proprietary compressed tile layouts
1279/// (UBWC, AFBC, PVRIC, DCC) that cut memory bandwidth; eligibility
1280/// requires a hardware-only buffer ([`CpuAccess::None`]) because CPU
1281/// mapping pins the layout linear. The request records best knowledge on
1282/// the tensor ([`Tensor::compression`]); it never changes what bytes a
1283/// consumer sees through the GPU/NPU.
1284#[non_exhaustive]
1285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286pub enum Compression {
1287 /// Let the platform use its native scheme when the format is
1288 /// eligible; otherwise allocate linear and count the fallback
1289 /// ([`compression_fallback_count`]). The right default for pipelines
1290 /// that want the bandwidth win without portability failures.
1291 Any,
1292 /// Require one specific scheme: allocation fails with
1293 /// [`Error::InvalidArgument`] when the device's scheme differs and
1294 /// [`Error::NotImplemented`] on platforms without vendor tile
1295 /// compression. For consumers whose ABI names a layout (e.g. a
1296 /// QNN context binary declaring UBWC inputs).
1297 Scheme(CompressionScheme),
1298}
1299
1300// Declared through `ef_vocabulary!` (see `vocabulary.rs`). This vocabulary
1301// gained a wire numbering when `ef_tensor_compression` was added: the
1302// dynamic backend reads a scheme back across the C boundary, so the enum
1303// needs codes that cannot drift from the C enumerators. Hand-writing that
1304// mapping in `edgefirst-tensor-capi` was the alternative and it is strictly
1305// worse -- `#[non_exhaustive]` makes a cross-crate `match` require a
1306// wildcard arm, so a scheme added here would silently map to "linear"
1307// there. Generated from one declaration, it cannot.
1308//
1309// Code 0 is deliberately unassigned and reserved for "linear" -- the state
1310// this crate spells `Option::None`, which has no variant to carry a code of
1311// its own. The C side names it `EF_COMPRESSION_NONE`.
1312crate::ef_vocabulary! {
1313 /// Vendor tile-compression schemes the HAL recognizes and records.
1314 ///
1315 /// Unrecognized platforms record `None` (linear) — there is deliberately
1316 /// no `Unknown` variant; a scheme is only recorded when the vendor is
1317 /// positively identified.
1318 #[non_exhaustive]
1319 pub enum CompressionScheme {
1320 /// Qualcomm Adreno Universal Bandwidth Compression.
1321 Ubwc = 1, "ubwc", UBWC,
1322 /// Arm Mali/Immortalis Framebuffer Compression.
1323 Afbc = 2, "afbc", AFBC,
1324 /// Imagination PowerVR Image Compression (Google Tensor G5+).
1325 Pvric = 3, "pvric", PVRIC,
1326 /// Samsung Xclipse (AMD RDNA) Delta Color Compression.
1327 Dcc = 4, "dcc", DCC,
1328 }
1329 // `#[doc(hidden)]` for the same reason `dtype_wire` is: emission
1330 // plumbing for `const`-only consumers, not a second documented API.
1331 #[doc(hidden)]
1332 pub mod compression_wire;
1333}
1334
1335/// Count of image allocations that requested [`Compression::Any`] but
1336/// resolved to a linear layout (ineligible format/dtype, unrecognized
1337/// vendor, or a platform without vendor tile compression). Read via
1338/// [`compression_fallback_count`].
1339static COMPRESSION_FALLBACKS: AtomicU64 = AtomicU64::new(0);
1340
1341/// Number of [`Compression::Any`] requests since process start that
1342/// resolved to a linear layout instead of a vendor tile scheme. A
1343/// steady-state pipeline holds this flat after warmup; growth means an
1344/// allocation path keeps requesting compression it never gets.
1345pub fn compression_fallback_count() -> u64 {
1346 COMPRESSION_FALLBACKS.load(Ordering::Relaxed)
1347}
1348
1349/// Record a `Compression::Any` request resolving linear.
1350///
1351/// `static`-only: the sole caller is `Tensor::image_desc` (`impl<T>
1352/// Tensor<T>`), itself `#[cfg(feature = "static")]`.
1353/// [`compression_fallback_count`] stays available under `dynamic` too --
1354/// it just always reads 0 there.
1355#[cfg(feature = "static")]
1356pub(crate) fn note_compression_fallback(detail: &str) {
1357 COMPRESSION_FALLBACKS.fetch_add(1, Ordering::Relaxed);
1358 log::debug!("compression request fell back to linear: {detail}");
1359}
1360
1361/// Whether this platform can allocate `(format, dtype)` images in a
1362/// vendor tile-compressed layout. `true` requires an Android build, an
1363/// eligible format (RGBA8888 `u8`/`i8` initially), and a positively
1364/// identified GPU vendor; everywhere else the answer is `false` and
1365/// [`Compression::Any`] requests fall back to linear.
1366pub fn compression_support(format: PixelFormat, dtype: DType) -> bool {
1367 #[cfg(target_os = "android")]
1368 {
1369 crate::ahardwarebuffer_layout::compression_eligible(format, dtype)
1370 && crate::ahardwarebuffer::device_compression_scheme().is_some()
1371 }
1372 #[cfg(not(target_os = "android"))]
1373 {
1374 let _ = (format, dtype);
1375 false
1376 }
1377}
1378
1379/// Declarative image-allocation request — the full-featured front door
1380/// for image tensors ([`TensorDyn::image_desc`] and the image crate's
1381/// `ImageProcessor::create_image_desc`).
1382///
1383/// The classic constructors (`image`, `create_image`) cover the common
1384/// cases; the desc carries the optional requests — today the
1385/// [`Compression`] request — without another constructor-parameter
1386/// sweep. Fields are private and the builders consume/return by value,
1387/// so future options are non-breaking.
1388///
1389/// ```
1390/// use edgefirst_tensor::{Compression, CpuAccess, DType, ImageDesc, PixelFormat};
1391/// let desc = ImageDesc::new(640, 640, PixelFormat::Rgba, DType::U8)
1392/// .with_access(CpuAccess::None)
1393/// .with_compression(Compression::Any);
1394/// ```
1395#[derive(Debug, Clone)]
1396pub struct ImageDesc {
1397 width: usize,
1398 height: usize,
1399 format: PixelFormat,
1400 dtype: DType,
1401 memory: Option<TensorMemory>,
1402 access: CpuAccess,
1403 compression: Option<Compression>,
1404}
1405
1406impl ImageDesc {
1407 /// A new image request: auto-selected memory, [`CpuAccess::None`]
1408 /// (hardware-only), no compression request.
1409 pub fn new(width: usize, height: usize, format: PixelFormat, dtype: DType) -> Self {
1410 Self {
1411 width,
1412 height,
1413 format,
1414 dtype,
1415 memory: None,
1416 access: CpuAccess::None,
1417 compression: None,
1418 }
1419 }
1420
1421 /// Request a specific memory backing (`None` = auto-select).
1422 pub fn with_memory(mut self, memory: Option<TensorMemory>) -> Self {
1423 self.memory = memory;
1424 self
1425 }
1426
1427 /// Declare the CPU access (see [`CpuAccess`]). Any declaration other
1428 /// than `None` makes a compression request invalid.
1429 pub fn with_access(mut self, access: CpuAccess) -> Self {
1430 self.access = access;
1431 self
1432 }
1433
1434 /// Request a tile-compressed layout (see [`Compression`]).
1435 pub fn with_compression(mut self, compression: Compression) -> Self {
1436 self.compression = Some(compression);
1437 self
1438 }
1439
1440 /// Requested width in pixels.
1441 pub fn width(&self) -> usize {
1442 self.width
1443 }
1444
1445 /// Requested height in pixels.
1446 pub fn height(&self) -> usize {
1447 self.height
1448 }
1449
1450 /// Requested pixel format.
1451 pub fn format(&self) -> PixelFormat {
1452 self.format
1453 }
1454
1455 /// Requested element type.
1456 pub fn dtype(&self) -> DType {
1457 self.dtype
1458 }
1459
1460 /// Requested memory backing (`None` = auto-select).
1461 pub fn memory(&self) -> Option<TensorMemory> {
1462 self.memory
1463 }
1464
1465 /// Declared CPU access.
1466 pub fn access(&self) -> CpuAccess {
1467 self.access
1468 }
1469
1470 /// The compression request, if any.
1471 pub fn compression(&self) -> Option<Compression> {
1472 self.compression
1473 }
1474}
1475
1476/// What kind of system key an identity was derived from. Participates in the
1477/// id so that, say, inode 7 and IOSurfaceID 7 cannot collide.
1478///
1479/// Invariant every variant must uphold: the key passed to
1480/// [`BufferIdentity::derived`] is unique among live buffers of that kind
1481/// **that own an allocation**. There is no "no identity" case — where no
1482/// system-wide key exists a process-local one does (a pointer, a GL name),
1483/// and a process-local key is exactly as sound as a system-wide one:
1484/// pointer/name reuse after free is the same ABA hazard as inode reuse, and
1485/// is mitigated the same way, by the retaining cache (see the design spec's
1486/// Task 6).
1487///
1488/// The allocation qualifier is load-bearing, not a hedge. A zero-element
1489/// tensor (`[0, num_protos]`, the tracker's "no detections this frame"
1490/// sentinel — see [`crate::mem`]) owns no allocation, so its `Vec` yields the
1491/// dangling well-aligned pointer `align_of::<T>()`. Every zero-element
1492/// `Tensor<f32>` in a process therefore shares one identity, and can even
1493/// collide across kinds with a small integer key from another variant.
1494///
1495/// That is safe rather than tolerated: an identity exists to answer "are
1496/// these the same bytes?", and buffers with no bytes have nothing to alias.
1497/// Nor can they reach an import cache. There are four GPU import paths —
1498/// a dma-buf fd on Linux, an IOSurface ref on macOS, an `AHardwareBuffer` on
1499/// Android, and an `ID3D11Texture2D` on Windows — and each takes only its own
1500/// object, so a tensor with no allocation reaches none of them. The Windows
1501/// path additionally keys on the texture the import itself holds a reference
1502/// to, and `edgefirst-image` **refuses** any texture whose identity is not
1503/// `D3d11Texture` over that same pointer — in every build, and on every
1504/// import including one served from its cache
1505/// (`GlPlatform::validate_import_identity`). That refusal exists because the
1506/// rule was once only written
1507/// down: the dynamic backend handed the image crate `HostPtr` identities over
1508/// recyclable handle addresses, and converts silently rendered into dropped
1509/// textures. Do not "fix" the zero-element case by minting a distinct id per
1510/// empty tensor: that reintroduces a counter, which is the state this whole
1511/// design exists to remove.
1512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1513pub enum IdentityKind {
1514 /// `(st_dev, st_ino)` of a dma-buf or memfd. Survives `dup`, and is the
1515 /// same value in every process that holds the buffer.
1516 DmaBuf,
1517 Shm,
1518 /// `IOSurfaceGetID`.
1519 IoSurface,
1520 /// `AHardwareBuffer_getId`, API 31+.
1521 AHardwareBufferId,
1522 /// The `AHardwareBuffer*` pointer, pre-API-31 where no id exists.
1523 /// Process-local: unique among live buffers here, meaningless elsewhere.
1524 AHardwareBufferPtr,
1525 /// An `ID3D11Texture2D*`. Process-local, like `AHardwareBufferPtr`, and
1526 /// the same pointer for every kind of texture tensor: one the HAL
1527 /// allocated, one wrapped from a caller's pointer, and one opened from a
1528 /// shared NT handle all key on the texture object they hold.
1529 ///
1530 /// **Both backends derive it from the same pointer, so their ids are
1531 /// equal byte for byte.** The static backend reads the `ComPtr` it owns
1532 /// (`d3d11::texture::tex_key`); the dynamic backend reads the same
1533 /// pointer back across the ABI with `ef_tensor_d3d11_texture`
1534 /// (`TensorDyn::derive_identity`). That equality is the point, not a
1535 /// coincidence: [`BufferIdentity::derived`] exists so two independently
1536 /// linked copies of this crate name one buffer identically, and
1537 /// `edgefirst-image` links the dynamic backend inside every `-capi` leaf
1538 /// while its own tests link the static one.
1539 ///
1540 /// **An `ef_tensor` handle's address must never be the key.** It is not a
1541 /// buffer at all, nothing the cache holds keeps it alive, and this
1542 /// library's allocator hands a freed handle's address to the very next
1543 /// one it mints — measured at 400 texture handles over 4 distinct
1544 /// addresses. The dynamic backend did exactly that on Windows until
1545 /// 2026-09-04, and a convert whose destination key collided rendered into
1546 /// the dropped texture while returning success.
1547 ///
1548 /// A handle import therefore does **not** compare equal to the tensor it
1549 /// was exported from, nor to a second import of the same handle:
1550 /// `OpenSharedResource1` mints a fresh `ID3D11Texture2D` per open. Keying
1551 /// on a handle value would make those compare equal, and would be unsound:
1552 /// Windows recycles a closed handle value straight into the next
1553 /// `DuplicateHandle`, so a consumer that caches on this id past the
1554 /// producing tensor's lifetime -- as the image crate's EGLImage cache
1555 /// does -- could serve one texture's entry for a later, unrelated import.
1556 /// A COM object's address cannot be recycled that way: a cache that holds
1557 /// a reference to the texture keeps the address taken for as long as its
1558 /// entry can be found. The cost is a cache miss per re-import instead of
1559 /// a wrong image.
1560 D3d11Texture,
1561 /// A host mapping address. Process-local.
1562 HostPtr,
1563 /// A GL buffer name. Valid only in the context that created it.
1564 Pbo,
1565}
1566
1567/// Unique identity for a tensor's underlying buffer.
1568///
1569/// Created fresh on every buffer allocation or import. The `id` is a cache
1570/// key, `kind` records what system key (if any) it was derived from, and the
1571/// `guard` is an `Arc<()>` whose weak references allow downstream caches to
1572/// detect when the buffer has been dropped.
1573#[derive(Debug, Clone)]
1574pub struct BufferIdentity {
1575 id: u64,
1576 kind: IdentityKind,
1577 guard: Arc<()>,
1578}
1579
1580impl BufferIdentity {
1581 /// Derive an identity from a key the operating system already guarantees.
1582 ///
1583 /// Deliberately NOT a counter: two independently-linked copies of this
1584 /// crate must derive the same id for the same buffer, or a consumer's
1585 /// import cache misses every frame. See the design spec, "The core finding".
1586 pub fn derived(kind: IdentityKind, key: u64) -> Self {
1587 // Mix the discriminant into the high bits so different kinds with the
1588 // same numeric key never collide.
1589 let id = ((kind as u64) << 56) ^ key;
1590 Self {
1591 id,
1592 kind,
1593 guard: Arc::new(()),
1594 }
1595 }
1596
1597 /// Unique identifier for this buffer. Changes when the buffer changes.
1598 pub fn id(&self) -> u64 {
1599 self.id
1600 }
1601
1602 /// What kind of system key this identity was derived from.
1603 pub fn kind(&self) -> IdentityKind {
1604 self.kind
1605 }
1606
1607 /// Returns a weak reference to the buffer guard. Goes dead when the
1608 /// owning Tensor is dropped (and no clones remain).
1609 pub fn weak(&self) -> Weak<()> {
1610 Arc::downgrade(&self.guard)
1611 }
1612}
1613
1614// `static`-only from here through `fs_magic` below: every use site lives in
1615// `impl<T> TensorStorage<T>` (`from_fd`'s DMA-BUF-vs-tmpfs classification),
1616// itself `#[cfg(feature = "static")]`. `dynamic`'s `from_fd`
1617// (`dynamic_backend.rs`) drives `ef_tensor_builder_wrap` instead, which
1618// does this same classification inside `libedgefirst_tensor.so`.
1619#[cfg(all(target_os = "linux", feature = "static"))]
1620use nix::sys::stat::{major, minor};
1621
1622/// Filesystem magic of the internal `dma_buf` mount, from
1623/// `include/uapi/linux/magic.h` (`DMA_BUF_MAGIC`, "DMAB").
1624///
1625/// This is stable UAPI and is the only reliable way to recognize a DMA-BUF
1626/// fd — see [`TensorStorage::from_fd`] for why `st_dev` cannot be used.
1627#[cfg(all(target_os = "linux", feature = "static"))]
1628const DMA_BUF_MAGIC: u32 = 0x444d_4142;
1629
1630/// Filesystem magic of tmpfs, from `include/uapi/linux/magic.h`
1631/// (`TMPFS_MAGIC`). Covers both POSIX `shm_open` segments under `/dev/shm`
1632/// and anonymous `memfd_create` files.
1633#[cfg(all(target_os = "linux", feature = "static"))]
1634const TMPFS_MAGIC: u32 = 0x0102_1994;
1635
1636/// Normalize a raw `fstatfs` `f_type` to the 32-bit unsigned magic that
1637/// `include/uapi/linux/magic.h` documents.
1638///
1639/// The width and signedness of `f_type` are target-dependent: `__fsword_t`
1640/// on Linux/gnu (`i64` on 64-bit, `i32` on 32-bit targets such as armv7,
1641/// i686 and aarch64-ilp32), `c_int` on uclibc, `c_ulong` on musl, and
1642/// `c_uint` on s390x. Where it is signed and 32 bits wide, widening it
1643/// sign-extends every magic with bit 31 set — hugetlbfs (`0x958458f6`),
1644/// btrfs (`0x9123683e`), f2fs (`0xf2f52010`) — which would make the value
1645/// reported by [`Error::UnknownBufferType`] impossible to find in the
1646/// header the docs point callers at.
1647///
1648/// Every magic is a 32-bit quantity, so truncating back to `u32` recovers
1649/// the true value from all four representations and loses nothing.
1650#[cfg(all(target_os = "linux", feature = "static"))]
1651const fn fs_magic(raw: i64) -> u32 {
1652 raw as u32
1653}
1654
1655/// The operations every memory backend implements.
1656///
1657/// This is the seam that lets [`Tensor<T>`] hide which backend is in play.
1658/// The implementors are `DmaTensor` (Linux DMA-BUF), `IoSurfaceTensor`
1659/// (macOS/iOS), `AHardwareBufferTensor` (Android), `ShmTensor`, `MemTensor`,
1660/// and [`PboTensor`]; `TensorStorage<T>` dispatches to whichever is active.
1661/// Backend types are not public — allocate a [`Tensor<T>`] or [`TensorDyn`]
1662/// and call these methods through it.
1663///
1664/// Import the trait to get `shape`, `size`, `map`, `clone_fd`,
1665/// `buffer_identity`, and the zero-copy sub-region `view` on a tensor value.
1666/// `Tensor::view` and `Tensor::batch` route through [`TensorTrait::view`], so
1667/// each backend's identity-sharing rule lives in exactly one place.
1668///
1669/// [`TensorDyn`]: crate::TensorDyn
1670pub trait TensorTrait<T>: Send + Sync
1671where
1672 T: Num + Clone + fmt::Debug,
1673{
1674 /// Create a new tensor with the given shape and optional name. If no name
1675 /// is given, a random name will be generated.
1676 fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
1677 where
1678 Self: Sized;
1679
1680 #[cfg(unix)]
1681 /// Import an existing buffer as a tensor, taking ownership of its file
1682 /// descriptor. The buffer is adopted in place — no bytes are copied.
1683 ///
1684 /// The backend is **detected**, not chosen: the fd already belongs to a
1685 /// buffer type, and this call's job is to recognize which. On Linux that
1686 /// is decided by the fd's filesystem magic, which is stable UAPI
1687 /// (`include/uapi/linux/magic.h`):
1688 ///
1689 /// | Filesystem | Magic | Resulting [`TensorMemory`] |
1690 /// |------------|-------|----------------------------|
1691 /// | `dma_buf` | `DMA_BUF_MAGIC` (`0x444d4142`) | [`TensorMemory::DmaBuf`] |
1692 /// | `tmpfs` (`/dev/shm` **and** `memfd`) | `TMPFS_MAGIC` (`0x01021994`) | [`TensorMemory::Shm`] |
1693 /// | anything else | — | *rejected* — see Errors |
1694 ///
1695 /// Both supported types are identified **positively**. An unrecognized
1696 /// filesystem is an error, never a fallback to shared memory: the wrong
1697 /// branch does not fail loudly (a DMA-BUF is mmap-able, so it would
1698 /// import as a perfectly functional tensor that merely isn't DMA, and a
1699 /// pipe would import as a zero-length one), so guessing would trade a
1700 /// clear error here for silent loss of zero-copy far downstream.
1701 ///
1702 /// The device number is deliberately **not** consulted. `dma_buf` files
1703 /// live on an internal kernel mount whose `st_dev` comes from
1704 /// `get_anon_bdev()` — an IDA shared with every other anonymous
1705 /// pseudo-filesystem and allocated in boot order — so the minor a
1706 /// DMA-BUF lands on varies by kernel build and is not part of any ABI.
1707 ///
1708 /// On non-Linux Unix (macOS/iOS/Android) there is no fd-based DMA import;
1709 /// the fd is always adopted as [`TensorMemory::Shm`].
1710 ///
1711 /// # Arguments
1712 ///
1713 /// * `fd` - Owned descriptor for the buffer to import. Ownership
1714 /// transfers to the returned tensor and the fd is closed on drop; pass
1715 /// [`clone_fd`](TensorTrait::clone_fd) output to keep your own handle.
1716 /// * `shape` - Logical shape to interpret the buffer with. Must describe
1717 /// no more elements than the buffer holds.
1718 /// * `name` - Optional name; a random one is generated when `None`.
1719 ///
1720 /// # Returns
1721 ///
1722 /// A tensor sharing the imported buffer's memory, whose
1723 /// [`memory()`](TensorTrait::memory) reports the detected backend.
1724 ///
1725 /// # Errors
1726 ///
1727 /// * [`Error::UnknownBufferType`] - the fd is on a filesystem that is
1728 /// neither `dma_buf` nor `tmpfs`, so its buffer type cannot be
1729 /// determined. Carries the observed `fstatfs` magic as a `u32`,
1730 /// normalized so it can be looked up in `magic.h` directly on both
1731 /// 32- and 64-bit targets. Typical causes: a regular file, a pipe or
1732 /// socket, or a `MFD_HUGETLB` memfd (hugetlbfs, not tmpfs). Linux only.
1733 /// * [`Error::UnknownDeviceType`] - the fd's `st_dev` major is non-zero,
1734 /// i.e. it lives on a real block device rather than an anonymous or
1735 /// in-memory filesystem. Linux only.
1736 /// * [`Error::InvalidSize`] - `shape` is empty or describes zero
1737 /// elements.
1738 /// * [`Error::NixError`] - `fstat`, `fstatfs`, or `mmap` failed on the
1739 /// descriptor.
1740 ///
1741 /// # Examples
1742 ///
1743 /// ```no_run
1744 /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
1745 ///
1746 /// # fn main() -> edgefirst_tensor::Result<()> {
1747 /// let src = Tensor::<u8>::new(&[480, 640, 3], Some(TensorMemory::DmaBuf), None)?;
1748 ///
1749 /// // Round-tripping a DMA-BUF fd preserves the backend — the import is
1750 /// // still zero-copy, and still eligible for GPU/NPU paths.
1751 /// let imported = Tensor::<u8>::from_fd(src.clone_fd()?, src.shape(), None)?;
1752 /// assert_eq!(imported.memory(), TensorMemory::DmaBuf);
1753 /// # Ok(())
1754 /// # }
1755 /// ```
1756 fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
1757 where
1758 Self: Sized;
1759
1760 #[cfg(unix)]
1761 /// Clone the file descriptor associated with this tensor.
1762 fn clone_fd(&self) -> Result<std::os::fd::OwnedFd>;
1763
1764 /// Get the memory type of this tensor.
1765 fn memory(&self) -> TensorMemory;
1766
1767 /// Get the name of this tensor.
1768 fn name(&self) -> String;
1769
1770 /// Get the number of elements in this tensor.
1771 fn len(&self) -> usize {
1772 self.shape().iter().product()
1773 }
1774
1775 /// Check if the tensor is empty.
1776 fn is_empty(&self) -> bool {
1777 self.len() == 0
1778 }
1779
1780 /// Get the size in bytes of this tensor.
1781 fn size(&self) -> usize {
1782 self.len() * std::mem::size_of::<T>()
1783 }
1784
1785 /// Get the shape of this tensor.
1786 fn shape(&self) -> &[usize];
1787
1788 /// Reshape this tensor to the given shape. The total number of elements
1789 /// must remain the same.
1790 fn reshape(&mut self, shape: &[usize]) -> Result<()>;
1791
1792 /// Bytes of the underlying allocation (>= the current logical `size()`).
1793 /// Defaults to the logical size for storages without spare capacity.
1794 fn capacity_bytes(&self) -> usize {
1795 self.size()
1796 }
1797
1798 /// Set the logical shape to any shape whose byte size fits the allocation
1799 /// capacity, without the equal-size constraint of `reshape`.
1800 fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
1801 self.reshape(shape)
1802 }
1803
1804 /// Map the tensor into memory with the given access direction and
1805 /// return a TensorMap for accessing the data.
1806 ///
1807 /// `access` selects the platform mapping mode (read-only IOSurface
1808 /// lock, dma-buf sync direction, AHardwareBuffer lock usage) and the
1809 /// map's mutability: a map obtained with [`CpuAccess::Read`] rejects
1810 /// `as_mut_slice`. [`CpuAccess::None`] is not a mappable direction
1811 /// and returns [`Error::InvalidArgument`].
1812 ///
1813 /// Prefer the typed wrappers [`map_read`](Self::map_read) /
1814 /// [`map_write`](Self::map_write) / [`map_mut`](Self::map_mut).
1815 /// Map for CPU access.
1816 ///
1817 /// Like [`pin_host`](Tensor::pin_host), `'a` bounds how long the backing
1818 /// allocation lives rather than borrowing the tensor: the returned view
1819 /// shares ownership through the pin's keepalive, so a map can coexist with
1820 /// a `&mut` on the same tensor.
1821 ///
1822 /// # Windows D3D11 textures: a write-only map of a partial window
1823 ///
1824 /// A [`CpuAccess::Write`] map is not refreshed from the texture first, so
1825 /// the bytes the caller leaves untouched are undefined -- the documented
1826 /// contract a whole-tensor map can meet and a sub-view cannot, since the
1827 /// window spans its own rows only.
1828 ///
1829 /// A tensor allocated [`CpuAccess::Write`] (no staging texture) publishes
1830 /// only the window's own rows on unmap, so a sub-view -- a
1831 /// [`view`](Tensor::view), or a shape narrowed with `set_logical_shape`
1832 /// -- is refused only when its window is not a whole, pitch-aligned row
1833 /// range (a plain-offset [`view`](Tensor::view) always is; an arbitrary
1834 /// byte offset from `set_plane_offset` might not be), which fails with
1835 /// [`Error::InvalidArgument`].
1836 ///
1837 /// A tensor allocated [`CpuAccess::Read`] or [`CpuAccess::ReadWrite`]
1838 /// (staging texture) has no such per-window publish: unmapping always
1839 /// copies the whole staging texture back, so a write-only sub-view is
1840 /// refused with [`Error::PartialWriteRequiresReadWrite`], naming
1841 /// [`CpuAccess::ReadWrite`] as the same window with the refresh that
1842 /// makes the untouched rows well-defined -- and, unlike the
1843 /// `CpuAccess::Write` case above, that retry always succeeds.
1844 fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1845 where
1846 T: 'a;
1847
1848 /// Map the tensor read-write (equivalent to
1849 /// `map_with(CpuAccess::ReadWrite)` — the historical `map()`
1850 /// behavior).
1851 fn map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1852 where
1853 T: 'a,
1854 {
1855 self.map_with(CpuAccess::ReadWrite)
1856 }
1857
1858 /// Map the tensor for CPU reading only. The returned map rejects
1859 /// `as_mut_slice`; on macOS this takes the read-only IOSurface lock
1860 /// (skips the unlock flush), on Linux the dma-buf read-direction
1861 /// sync.
1862 fn map_read<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1863 where
1864 T: 'a,
1865 {
1866 self.map_with(CpuAccess::Read)
1867 }
1868
1869 /// Map the tensor for CPU writing (fill-only: reading through a
1870 /// write map may see write-combined memory — do not read the slice).
1871 fn map_write<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1872 where
1873 T: 'a,
1874 {
1875 self.map_with(CpuAccess::Write)
1876 }
1877
1878 /// Map the tensor read-write (alias of [`map`](Self::map) with the
1879 /// intent spelled out).
1880 fn map_mut<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1881 where
1882 T: 'a,
1883 {
1884 self.map_with(CpuAccess::ReadWrite)
1885 }
1886
1887 /// Non-blocking [`map_with`](Self::map_with): returns
1888 /// `Err(Error::IoError(WouldBlock))` while a GPU copy the map depends on
1889 /// is still in flight, and makes progress on a retry.
1890 ///
1891 /// Backings with no such copy (host memory, shared memory, dma-buf,
1892 /// IOSurface, AHardwareBuffer, PBO) alias `map_with`, so a caller can use
1893 /// this unconditionally and only ever see `WouldBlock` where the wait is
1894 /// real -- today the Windows D3D11 texture's staging refresh.
1895 ///
1896 /// A retry loop must yield or sleep between attempts. On the WARP
1897 /// software adapter the CPU threads are the GPU, so a tight loop starves
1898 /// the very copy it is waiting for.
1899 ///
1900 /// [`CpuAccess::Write`] is refused on a Windows D3D11 texture tensor whose
1901 /// window is shorter than its backing, exactly as it is for
1902 /// [`map_with`](Self::map_with); see that method.
1903 fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1904 where
1905 T: 'a,
1906 {
1907 self.map_with(access)
1908 }
1909
1910 /// Non-blocking [`map`](Self::map): read-write, and `WouldBlock` rather
1911 /// than a stall. See [`try_map_with`](Self::try_map_with).
1912 fn try_map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1913 where
1914 T: 'a,
1915 {
1916 self.try_map_with(CpuAccess::ReadWrite)
1917 }
1918
1919 /// Get the buffer identity for cache keying and liveness tracking.
1920 fn buffer_identity(&self) -> &BufferIdentity;
1921
1922 /// Create a zero-copy sub-region view of this backing that shares the
1923 /// underlying allocation **and** [`BufferIdentity`].
1924 ///
1925 /// The window is `[offset_bytes, offset_bytes + shape.product() *
1926 /// size_of::<T>())` measured from this tensor's own logical start, so a
1927 /// sub-view of a sub-view composes by adding offsets. Sharing the parent's
1928 /// identity is the contract that lets identity-keyed caches (e.g. the GL
1929 /// EGLImage import cache) treat offset-distinct windows as one buffer rather
1930 /// than unrelated allocations — `view` must never mint a fresh identity.
1931 ///
1932 /// Defaults to [`Error::NotImplemented`]; every backend that supports
1933 /// sub-views overrides it (`Mem`, `Shm`, Linux DMA, macOS IOSurface, `Pbo`).
1934 fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self>
1935 where
1936 Self: Sized,
1937 {
1938 let _ = (offset_bytes, shape);
1939 Err(Error::NotImplemented(
1940 "view (zero-copy sub-region) is not supported for this tensor backend".to_owned(),
1941 ))
1942 }
1943}
1944
1945pub trait TensorMapTrait<T>
1946where
1947 T: Num + Clone + fmt::Debug,
1948{
1949 /// Get the shape of this tensor map.
1950 fn shape(&self) -> &[usize];
1951
1952 /// Unmap the tensor from memory.
1953 fn unmap(&mut self);
1954
1955 /// Get the number of elements in this tensor map.
1956 fn len(&self) -> usize {
1957 self.shape().iter().product()
1958 }
1959
1960 /// Check if the tensor map is empty.
1961 fn is_empty(&self) -> bool {
1962 self.len() == 0
1963 }
1964
1965 /// Get the size in bytes of this tensor map.
1966 fn size(&self) -> usize {
1967 self.len() * std::mem::size_of::<T>()
1968 }
1969
1970 /// Get a slice to the data in this tensor map.
1971 fn as_slice(&self) -> &[T];
1972
1973 /// Get a mutable slice to the data in this tensor map.
1974 fn as_mut_slice(&mut self) -> &mut [T];
1975
1976 /// Whether this mapping permits writes -- i.e. whether the coherency
1977 /// bracket it holds will flush on release.
1978 ///
1979 /// The single source of truth for a binding that has to advertise
1980 /// mutability to a foreign consumer (the Python buffer protocol's
1981 /// `readonly` flag, say). Deriving that from the access a caller
1982 /// *requested* instead would leave the advertisement agreeing with the
1983 /// request while both disagreed with the mapping actually taken.
1984 fn is_writable(&self) -> bool;
1985
1986 #[cfg(feature = "ndarray")]
1987 /// Get an ndarray ArrayView of the tensor data.
1988 fn view(&'_ self) -> Result<ndarray::ArrayView<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
1989 Ok(ndarray::ArrayView::from_shape(
1990 self.shape(),
1991 self.as_slice(),
1992 )?)
1993 }
1994
1995 #[cfg(feature = "ndarray")]
1996 /// Get an ndarray ArrayViewMut of the tensor data.
1997 fn view_mut(
1998 &'_ mut self,
1999 ) -> Result<ndarray::ArrayViewMut<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
2000 let shape = self.shape().to_vec();
2001 Ok(ndarray::ArrayViewMut::from_shape(
2002 shape,
2003 self.as_mut_slice(),
2004 )?)
2005 }
2006}
2007
2008// Declared through `ef_vocabulary!` (see `vocabulary.rs`). Before this, the
2009// same four backings were numbered three different ways -- Rust's implicit
2010// discriminants (`Dma=0, Shm=1, Mem=2, Pbo=3`), the C ABI's
2011// `hal_tensor_memory` (`MEM=0, DMA=1, SHM=2, PBO=3`), and Python's
2012// `PyTensorMemory`, which had no explicit discriminants at all and a
2013// `#[cfg(unix)]` variant in the middle of the list, so its numbering
2014// depended on the target OS. The canonical assignment below is the one all
2015// of them converge on; `hal_tensor_memory` is the single explicitly-mapped
2016// outlier that keeps its own released values (see `to_hal_tensor_memory` in
2017// the capi crate).
2018//
2019// Note this is NOT `protocol::kind`, which is a genuinely different
2020// vocabulary and stays so: `kind` describes what a `TensorDesc`'s `handle`
2021// and `ptr` fields MEAN to an importer, and both `Mem` and `Shm` mean the
2022// same thing there (`kind::HOST`, host-addressable pointer, no handle). A
2023// vocabulary that collapses two variants into one cannot also be the
2024// vocabulary that distinguishes them. `kind_of()` in `protocol.rs` is the
2025// mapping between them.
2026crate::ef_vocabulary! {
2027 /// Which memory backend a tensor is (or should be) allocated from.
2028 ///
2029 /// Pass `Some(..)` to a constructor to pin the backend, or `None` to
2030 /// auto-select. Auto-selection differs by constructor: [`Tensor::new`]
2031 /// tries `DmaBuf` → `Mem`, while the image constructors try `DmaBuf` →
2032 /// `Mem` and skip `Shm`. `EDGEFIRST_TENSOR_FORCE_MEM=1` short-circuits
2033 /// either chain to `Mem`, which is how tests run on hosts without
2034 /// DMA-heap permissions.
2035 ///
2036 /// A pinned request has no fallback: if the backend cannot serve it, the
2037 /// constructor fails rather than quietly giving you something slower.
2038 /// [`TensorTrait::memory`] reports what a tensor actually ended up with.
2039 ///
2040 /// # Every variant exists on every platform
2041 ///
2042 /// This is a namespace of codes, not a list of what this build can
2043 /// allocate -- [`TensorMemory::is_available`] answers the latter, at
2044 /// runtime. `Shm` used to be `#[cfg(unix)]`, which shifted every
2045 /// variant declared after it on a non-unix build and made the
2046 /// numbering a compile-time detail of the host. It also made a
2047 /// cross-platform recording unreadable: a blob captured on Linux
2048 /// carries `"dmabuf"`, and a macOS consumer has to be able to NAME
2049 /// that backing in order to report "cannot materialise here" instead
2050 /// of failing with an unrecognised code.
2051 ///
2052 /// # `#[non_exhaustive]`
2053 ///
2054 /// Match this with a wildcard arm. The variant list is expected to
2055 /// keep growing -- `IoSurface` and `Cuda` were added here, Windows
2056 /// backings and a real CUDA device backing are named as coming -- and
2057 /// this attribute is what keeps a closed-source consumer's
2058 /// `match memory { .. }` compiling across those additions. It protects
2059 /// DOWNSTREAM, not this crate: inside `edgefirst-tensor` the matches
2060 /// stay exhaustive and compile-enforced. Same rule as [`DType`],
2061 /// [`Compression`] and [`PixelFormat`], which `TensorMemory` was the
2062 /// lone exception to.
2063 #[non_exhaustive]
2064 pub enum TensorMemory {
2065 /// Regular system memory allocation. Available everywhere; the
2066 /// floor every auto-selection chain ends on.
2067 Mem = 0, "mem", MEM,
2068 /// POSIX Shared Memory allocation. Suitable for inter-process
2069 /// communication, but not for hardware acceleration. Only
2070 /// allocatable on unix; nameable everywhere.
2071 Shm = 1, "shm", SHM,
2072 /// Platform-native zero-copy GPU buffer.
2073 ///
2074 /// On Linux this is a DMA-BUF (`DmaTensor` in
2075 /// `crates/tensor/src/dma.rs`) allocated via the DRM/dma-heap
2076 /// subsystem. On macOS/iOS it is currently also this variant that
2077 /// yields an IOSurface, and on Android an AHardwareBuffer: they
2078 /// share the `TensorStorage::Dma` slot at the trait level, and the
2079 /// public C API discriminant (`HAL_TENSOR_MEMORY_DMA = 1`) covers
2080 /// all three with no ABI break. `IoSurface` below is the code for
2081 /// naming that backing specifically; no backend reports it yet.
2082 ///
2083 /// Allows hardware-accelerated paths (OpenGL backend on Linux via
2084 /// `EGL_EXT_image_dma_buf_import`; macOS via
2085 /// `EGL_ANGLE_iosurface_client_buffer`). CPU access via `map()`
2086 /// incurs cache-coherency overhead on Linux DMA-BUF and is similar
2087 /// in cost on IOSurface; SHM/Mem are cheaper for CPU-only workloads.
2088 ///
2089 /// On Windows it is an `ID3D11Texture2D` on the process D3D11 device
2090 /// (`crates/tensor/src/d3d11/`), allocated only through
2091 /// [`Tensor::image`](crate::Tensor::image); CPU access goes through a
2092 /// staging copy. The first such allocation creates the process
2093 /// device, so a host supplying its own device must call
2094 /// `d3d11::use_external_device` before it -- and before
2095 /// [`crate::is_gpu_buffer_available`], which creates the device too.
2096 DmaBuf = 2, "dmabuf", DMABUF,
2097 /// Apple IOSurface, named specifically rather than through the
2098 /// portable `DmaBuf` spelling.
2099 ///
2100 /// Defined as a code so a descriptor or recording that names an
2101 /// IOSurface is parseable on every platform. **No backend produces
2102 /// or accepts it yet** -- macOS/iOS allocate and report `DmaBuf`.
2103 /// Pinning a request to it fails with `NotImplemented`.
2104 IoSurface = 3, "iosurface", IOSURFACE,
2105 /// OpenGL Pixel Buffer Object memory. Created by `ImageProcessor`
2106 /// when DMA-buf is unavailable but OpenGL is present -- never by
2107 /// [`Tensor::new`], which has no GL context to create it in.
2108 Pbo = 4, "pbo", PBO,
2109 /// CUDA device memory: a device pointer, not host-addressable.
2110 ///
2111 /// Defined as a code for the same reason as `IoSurface`. **No
2112 /// backend produces or accepts it yet**; pinning a request to it
2113 /// fails with `NotImplemented`.
2114 Cuda = 5, "cuda", CUDA,
2115 }
2116 // `#[doc(hidden)]`: `pub` so the const-only form is reachable by an FFI
2117 // or cbindgen consumer, but it is emission plumbing rather than a second
2118 // documented API -- `TensorMemory::code()` is the documented path, and
2119 // the two are generated from the same literal.
2120 #[doc(hidden)]
2121 pub mod tensor_memory_wire;
2122}
2123
2124impl TensorMemory {
2125 /// Whether this backing can actually be allocated in this process, now.
2126 ///
2127 /// A runtime question, not a compile-time one, which is why it is a
2128 /// method on the vocabulary rather than a `cfg` on the variant:
2129 /// `DmaBuf` needs `/dev/dma_heap` to exist *and* this process to have
2130 /// permission on it (a container, a user outside the `video`/`render`
2131 /// groups, and a stock desktop give three different answers on the same
2132 /// kernel), and `Shm` needs a writable `/dev/shm`. Each probe allocates
2133 /// once and caches.
2134 ///
2135 /// Reports what can be *allocated*, which is not the same as what can be
2136 /// *named*: every variant is nameable on every platform (that is the
2137 /// point of the vocabulary), and the ones this build cannot serve say so
2138 /// here rather than by being absent from the enum.
2139 ///
2140 /// Three variants answer `false` unconditionally today, for reasons
2141 /// worth stating rather than hiding behind a probe that cannot fail:
2142 ///
2143 /// * `IoSurface` -- no backend produces or accepts it yet; macOS/iOS
2144 /// serve IOSurface under the `DmaBuf` spelling, so `DmaBuf` is the
2145 /// variant that answers `true` there.
2146 /// * `Pbo` -- PBO tensors are created by `ImageProcessor::create_image`
2147 /// with a GL context current, never by [`Tensor::new`]. This crate
2148 /// holds no GL context and cannot see one; ask the image crate
2149 /// whether a GL backend is live.
2150 /// * `Cuda` -- there is no CUDA backing in this crate at all. Probing
2151 /// for `libcuda` would answer `true` on a Jetson while every
2152 /// allocation still failed, which is worse than answering `false`.
2153 ///
2154 /// One variant answers `true` for a narrower constructor than the rest:
2155 /// on Windows `DmaBuf` is an `ID3D11Texture2D`, which is image-formatted,
2156 /// so `true` there means [`Tensor::image`] can serve it and
2157 /// [`Tensor::new`] with `Some(DmaBuf)` still answers `NotImplemented`.
2158 /// [`is_gpu_buffer_available`](crate::is_gpu_buffer_available), which this
2159 /// forwards to, states the same split.
2160 pub fn is_available(self) -> bool {
2161 match self {
2162 // Always: the fallback every constructor chain ends on. If this
2163 // could fail, allocation would have no floor left.
2164 TensorMemory::Mem => true,
2165 TensorMemory::Shm => is_shm_available(),
2166 // Today `DmaBuf` is the portable spelling of "platform-native
2167 // zero-copy GPU buffer", so it is the portable probe that
2168 // answers for it -- true on macOS/iOS (IOSurface) and Android
2169 // (AHardwareBuffer) as well as Linux.
2170 TensorMemory::DmaBuf => is_gpu_buffer_available(),
2171 TensorMemory::IoSurface | TensorMemory::Pbo | TensorMemory::Cuda => false,
2172 }
2173 }
2174
2175 /// The error to return when a caller pins a request to a backing this
2176 /// build knows the name of but cannot serve.
2177 ///
2178 /// Deliberately not `unreachable!()`. `IoSurface` and `Cuda` are
2179 /// unreachable *today* only because no backend reports them yet; the
2180 /// day one does, an `unreachable!()` written now becomes a panic in
2181 /// library code reached from an ordinary caller request. A "this build
2182 /// cannot do that" error is the same information without the crash, and
2183 /// it is what every other unservable request here already returns.
2184 ///
2185 /// `static`-only: every call site is inside `impl<T> TensorStorage<T>`
2186 /// (`TensorStorage::new`), itself `#[cfg(feature = "static")]`.
2187 #[cfg(feature = "static")]
2188 pub(crate) fn unsupported_here(self) -> Error {
2189 let reason = match self {
2190 // Not reachable: `Mem` is servable on every target. Stated
2191 // rather than panicked on, for the reason above.
2192 TensorMemory::Mem => "Mem is servable on every platform, so this should not happen",
2193 TensorMemory::Shm => "POSIX shared memory is unix-only",
2194 TensorMemory::DmaBuf => {
2195 "this target has no platform GPU-buffer allocator (DMA-BUF is Linux, \
2196 IOSurface macOS/iOS, AHardwareBuffer Android, D3D11 textures on \
2197 Windows)"
2198 }
2199 TensorMemory::IoSurface => {
2200 "no backend produces or accepts IOSurface under its own code yet -- \
2201 macOS/iOS allocate and report TensorMemory::DmaBuf"
2202 }
2203 TensorMemory::Pbo => {
2204 "PBO tensors are created by ImageProcessor::create_image with a GL \
2205 context current, never by Tensor::new"
2206 }
2207 TensorMemory::Cuda => "there is no CUDA backing in this build",
2208 };
2209 Error::NotImplemented(format!(
2210 "TensorMemory::{self:?} is not supported by this build: {reason}"
2211 ))
2212 }
2213}
2214
2215impl From<TensorMemory> for String {
2216 /// The wire string, via [`TensorMemory::as_str`] -- one spelling, not a
2217 /// second one maintained alongside it.
2218 ///
2219 /// `DmaBuf` renders as `"dmabuf"`, not the `"dma"` this impl used to
2220 /// emit. `TryFrom<&str>` still accepts `"dma"` so anything that
2221 /// persisted the old spelling keeps parsing; the benchmark result keys
2222 /// in `.github/scripts` spell it `"dma"` but build that string
2223 /// themselves and never went through this impl.
2224 fn from(memory: TensorMemory) -> Self {
2225 memory.as_str().to_owned()
2226 }
2227}
2228
2229impl TryFrom<&str> for TensorMemory {
2230 type Error = Error;
2231
2232 /// Parses [`TensorMemory::as_str`]'s spelling, plus the legacy `"dma"`
2233 /// alias for `DmaBuf`.
2234 fn try_from(s: &str) -> Result<Self> {
2235 TensorMemory::from_str_code(s)
2236 .or(if s == "dma" {
2237 Some(TensorMemory::DmaBuf)
2238 } else {
2239 None
2240 })
2241 .ok_or_else(|| Error::InvalidMemoryType(s.to_owned()))
2242 }
2243}
2244
2245// Everything from here through the end of `impl<T> TensorTrait<T> for
2246// Tensor<T>` below is the `static` backend's storage-owning `Tensor<T>`:
2247// `TensorStorage<T>` (the per-memory-backend enum) and the `Tensor<T>`
2248// struct that wraps it. `dynamic`'s `Tensor<T>` is a different, much
2249// smaller type -- a typed lens over `TensorDyn` with no storage of its
2250// own -- defined in `tensor_dyn/dynamic_tensor.rs` and re-exported below.
2251#[cfg(feature = "static")]
2252#[derive(Debug)]
2253#[allow(dead_code)] // Variants are constructed by downstream crates via pub(crate) helpers
2254pub(crate) enum TensorStorage<T>
2255where
2256 T: Num + Clone + fmt::Debug + Send + Sync,
2257{
2258 /// Platform-native zero-copy GPU buffer. Inner type differs per
2259 /// target: `DmaTensor` on Linux (DMA-BUF fd), `IoSurfaceTensor` on
2260 /// macOS (CFRetained IOSurface). The shared variant name keeps the
2261 /// public `TensorMemory::DmaBuf` discriminant stable across platforms.
2262 #[cfg(target_os = "linux")]
2263 Dma(DmaTensor<T>),
2264 #[cfg(any(target_os = "macos", target_os = "ios"))]
2265 Dma(IoSurfaceTensor<T>),
2266 #[cfg(target_os = "android")]
2267 Dma(AHardwareBufferTensor<T>),
2268 #[cfg(target_os = "windows")]
2269 Dma(crate::d3d11::texture::D3d11TextureTensor<T>),
2270 #[cfg(unix)]
2271 Shm(ShmTensor<T>),
2272 Mem(MemTensor<T>),
2273 Pbo(PboTensor<T>),
2274}
2275
2276#[cfg(feature = "static")]
2277impl<T> TensorStorage<T>
2278where
2279 T: Num + Clone + fmt::Debug + Send + Sync,
2280{
2281 /// The backing allocation's intrinsic physical row pitch in bytes, if it
2282 /// has one that is fixed independent of the logical shape. macOS IOSurface
2283 /// reports its 64-aligned `bytesPerRow`; other backings (Linux DMA, SHM,
2284 /// Mem, PBO) have no fixed pitch beyond the logical shape and return `None`.
2285 ///
2286 /// Used by `configure_image` to preserve the physical pitch when a reused
2287 /// pool tensor is reconfigured to a smaller logical image — so the decode
2288 /// writes rows at the surface's real stride and the GPU samples them with
2289 /// the same stride (the physical-grid / logical-ROI decoupling).
2290 pub(crate) fn backing_row_stride(&self) -> Option<usize> {
2291 match self {
2292 // Only genuine image-formatted IOSurfaces (height > 1) carry a real
2293 // per-row pitch; a generic byte-bag (height == 1) returns `None` so
2294 // `configure_image` does not adopt its whole-buffer "row" as a stride.
2295 #[cfg(any(target_os = "macos", target_os = "ios"))]
2296 TensorStorage::Dma(t) => t.image_backing_row_stride(),
2297 // Android AHardwareBuffer: same rule — only genuine 2D
2298 // image-formatted buffers (height > 1) carry a real row pitch.
2299 #[cfg(target_os = "android")]
2300 TensorStorage::Dma(t) => t.image_backing_row_stride(),
2301 // Windows D3D11: the driver's staging row pitch. `None` when the
2302 // tensor has no staging (write-only or hardware-only), whose host
2303 // buffer has tight rows -- except for a semi-planar tensor, whose
2304 // texture is allocated as wide as that pitch for every access, so
2305 // it always has one to report.
2306 #[cfg(target_os = "windows")]
2307 TensorStorage::Dma(t) => t.image_backing_row_stride(),
2308 _ => None,
2309 }
2310 }
2311
2312 /// Create a new tensor storage with the given shape, memory type, and
2313 /// optional name. If no name is given, a random name will be generated.
2314 /// If no memory type is given, the best available memory type will be
2315 /// chosen based on the platform and environment variables.
2316 fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
2317 match memory {
2318 #[cfg(target_os = "linux")]
2319 Some(TensorMemory::DmaBuf) => {
2320 DmaTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2321 }
2322 #[cfg(any(target_os = "macos", target_os = "ios"))]
2323 Some(TensorMemory::DmaBuf) => {
2324 IoSurfaceTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2325 }
2326 #[cfg(target_os = "android")]
2327 Some(TensorMemory::DmaBuf) => {
2328 AHardwareBufferTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2329 }
2330 // Windows has a DmaBuf backing, but it is a D3D11 texture: an image
2331 // layout, not a byte bag, so a shape alone cannot describe one.
2332 // Name the constructor that can, rather than quietly handing back
2333 // host memory the caller did not ask for.
2334 #[cfg(target_os = "windows")]
2335 Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2336 "D3D11 texture tensors are image-formatted; use Tensor::image with \
2337 TensorMemory::DmaBuf"
2338 .to_owned(),
2339 )),
2340 #[cfg(not(any(
2341 target_os = "linux",
2342 target_os = "macos",
2343 target_os = "ios",
2344 target_os = "android",
2345 target_os = "windows"
2346 )))]
2347 Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2348 "TensorMemory::DmaBuf is only available on Linux (DMA-BUF), macOS/iOS (IOSurface), \
2349 Android (AHardwareBuffer), and Windows (D3D11 textures, via Tensor::image)"
2350 .to_owned(),
2351 )),
2352 #[cfg(unix)]
2353 Some(TensorMemory::Shm) => {
2354 ShmTensor::<T>::new(shape, name).map(TensorStorage::Shm)
2355 }
2356 #[cfg(not(unix))]
2357 Some(m @ TensorMemory::Shm) => Err(m.unsupported_here()),
2358 Some(TensorMemory::Mem) => {
2359 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2360 }
2361 Some(TensorMemory::Pbo) => Err(crate::error::Error::NotImplemented(
2362 "PboTensor cannot be created via Tensor::new() — use ImageProcessor::create_image()".to_owned(),
2363 )),
2364 // Defined codes with no backing behind them yet. An error, not
2365 // an `unreachable!()` -- see `unsupported_here`.
2366 Some(m @ (TensorMemory::IoSurface | TensorMemory::Cuda)) => Err(m.unsupported_here()),
2367 None => {
2368 if std::env::var("EDGEFIRST_TENSOR_FORCE_MEM")
2369 .is_ok_and(|x| x != "0" && x.to_lowercase() != "false")
2370 {
2371 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2372 } else {
2373 // Auto-select priority: Dma > Mem. Shm is intentionally NOT
2374 // auto-selected — it offers no advantage over Mem for an
2375 // in-process tensor and Mem always succeeds, so Shm below Mem
2376 // is effectively never reached. Request Shm explicitly via
2377 // `TensorMemory::Shm` when cross-process sharing is needed.
2378 // (PBO sits between Dma and Mem but is GL-backed and created
2379 // only via `ImageProcessor::create_image`.)
2380 #[cfg(target_os = "linux")]
2381 {
2382 // Linux: Try DMA -> Mem
2383 match DmaTensor::<T>::new(shape, name) {
2384 Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2385 Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2386 }
2387 }
2388 #[cfg(any(target_os = "macos", target_os = "ios"))]
2389 {
2390 // macOS/iOS: Try IOSurface -> Mem. IOSurface is the
2391 // GPU-shareable backend (zero-copy via ANGLE), filling the
2392 // same role as DMA-BUF on Linux.
2393 match IoSurfaceTensor::<T>::new(shape, name) {
2394 Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2395 Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2396 }
2397 }
2398 #[cfg(target_os = "android")]
2399 {
2400 // Android: Mem. Unlike macOS (where a byte-bag
2401 // IOSurface is still GL-importable as R8) a generic
2402 // BLOB AHardwareBuffer cannot back a GPU texture, so
2403 // auto-selecting it buys nothing and costs plenty: a
2404 // gralloc allocator-HAL ioctl per allocation (orders
2405 // slower than malloc, ≥1 page + a dmabuf fd even for
2406 // tiny tensors) and a lock/unlock cache-maintenance
2407 // round trip on every map(). Zero-copy image tensors
2408 // come from `Tensor::image(..)`; callers that want a
2409 // BLOB (NNAPI handoff) request `TensorMemory::DmaBuf`
2410 // explicitly.
2411 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2412 }
2413 #[cfg(all(
2414 unix,
2415 not(any(
2416 target_os = "linux",
2417 target_os = "macos",
2418 target_os = "ios",
2419 target_os = "android"
2420 ))
2421 ))]
2422 {
2423 // Other Unix (BSD): Mem only (no DMA; Shm is explicit-only)
2424 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2425 }
2426 #[cfg(not(unix))]
2427 {
2428 // Windows/other: Mem only
2429 MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2430 }
2431 }
2432 }
2433 }
2434 }
2435
2436 /// Create a DMA-backed tensor storage with an explicit byte size that
2437 /// may exceed `shape.product() * sizeof(T)`. Used for image tensors
2438 /// with row-padded layouts (see `DmaTensor::new_with_byte_size`).
2439 ///
2440 /// This is intentionally DMA-only: padding is only meaningful for
2441 /// buffers that will be imported as GPU textures via EGLImage. PBO,
2442 /// Shm, and Mem storage doesn't benefit from pitch alignment and
2443 /// shouldn't pay the memory cost.
2444 #[cfg(target_os = "linux")]
2445 pub(crate) fn new_dma_with_byte_size(
2446 shape: &[usize],
2447 byte_size: usize,
2448 name: Option<&str>,
2449 ) -> Result<Self> {
2450 DmaTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Dma)
2451 }
2452
2453 // No non-Linux stub: the only caller (`Tensor::image_with_stride`)
2454 // returns `NotImplemented` directly on non-Linux without ever
2455 // reaching the storage layer, so defining a stub here would be
2456 // dead code and fail the `-D warnings` clippy gate on macOS CI.
2457
2458 /// Create a Mem-backed tensor storage with an explicit byte size that may
2459 /// exceed `shape.product() * sizeof(T)`. Used for image tensors with
2460 /// 64-byte-aligned row strides (see `MemTensor::with_capacity_bytes`).
2461 pub(crate) fn new_mem_with_byte_size(
2462 shape: &[usize],
2463 byte_size: usize,
2464 name: Option<&str>,
2465 ) -> Result<Self>
2466 where
2467 T: 'static,
2468 {
2469 MemTensor::<T>::with_capacity_bytes(shape, byte_size, name).map(TensorStorage::Mem)
2470 }
2471
2472 /// Create a Shm-backed tensor storage with an explicit byte size that may
2473 /// exceed `shape.product() * sizeof(T)`. Used for image tensors with
2474 /// 64-byte-aligned row strides (see `ShmTensor::new_with_byte_size`).
2475 #[cfg(unix)]
2476 pub(crate) fn new_shm_with_byte_size(
2477 shape: &[usize],
2478 byte_size: usize,
2479 name: Option<&str>,
2480 ) -> Result<Self> {
2481 ShmTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Shm)
2482 }
2483
2484 /// Allocate an image-formatted IOSurface-backed storage (macOS).
2485 ///
2486 /// Used by `Tensor::image()` when the caller requests
2487 /// `TensorMemory::DmaBuf` and the format has an IOSurface FourCC
2488 /// mapping (YUYV, RGBA, BGRA today). Falls back to `new_with_byte_size`
2489 /// otherwise.
2490 #[cfg(any(target_os = "macos", target_os = "ios"))]
2491 pub(crate) fn new_image_iosurface(
2492 width: usize,
2493 height: usize,
2494 format: PixelFormat,
2495 dtype: DType,
2496 shape: &[usize],
2497 name: Option<&str>,
2498 ) -> Result<Self> {
2499 IoSurfaceTensor::<T>::new_image(width, height, format, dtype, shape, name)
2500 .map(TensorStorage::Dma)
2501 }
2502
2503 /// Allocate an image-formatted AHardwareBuffer-backed storage (Android).
2504 ///
2505 /// Used by `Tensor::image()` when the caller requests
2506 /// `TensorMemory::DmaBuf` and the format has an AHardwareBuffer format
2507 /// mapping (RGBA8 and the RGBA16F float paths today). Falls back to
2508 /// `new_with_byte_size` otherwise.
2509 #[cfg(target_os = "android")]
2510 pub(crate) fn new_image_ahardwarebuffer(
2511 width: usize,
2512 height: usize,
2513 format: PixelFormat,
2514 dtype: DType,
2515 shape: &[usize],
2516 name: Option<&str>,
2517 access: CpuAccess,
2518 ) -> Result<Self> {
2519 AHardwareBufferTensor::<T>::new_image(width, height, format, dtype, shape, name, access)
2520 .map(TensorStorage::Dma)
2521 }
2522
2523 /// Allocate an image-formatted D3D11 texture storage (Windows).
2524 ///
2525 /// Used by `Tensor::image()` when the caller requests
2526 /// `TensorMemory::DmaBuf`. Unlike the IOSurface and AHardwareBuffer
2527 /// constructors there is no byte-bag fallback: a format with no texture
2528 /// layout is an error, because a texture is the only thing this backing
2529 /// can be.
2530 #[cfg(target_os = "windows")]
2531 pub(crate) fn new_image_d3d11(
2532 width: usize,
2533 height: usize,
2534 format: PixelFormat,
2535 dtype: DType,
2536 shape: &[usize],
2537 name: Option<&str>,
2538 access: CpuAccess,
2539 ) -> Result<Self> {
2540 crate::d3d11::texture::D3d11TextureTensor::<T>::new_image(
2541 width, height, format, dtype, shape, name, access,
2542 )
2543 .map(TensorStorage::Dma)
2544 }
2545
2546 /// Create a new tensor storage using the given file descriptor, shape,
2547 /// and optional name.
2548 #[cfg(unix)]
2549 fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2550 #[cfg(target_os = "linux")]
2551 {
2552 use nix::sys::stat::fstat;
2553 use nix::sys::statfs::fstatfs;
2554
2555 let stat = fstat(&fd)?;
2556 let major = major(stat.st_dev);
2557 let minor = minor(stat.st_dev);
2558
2559 if major != 0 {
2560 // Dma and Shm tensors are expected to have major number 0
2561 return Err(Error::UnknownDeviceType(major, minor));
2562 }
2563
2564 // Classify by filesystem magic, never by the st_dev minor.
2565 //
2566 // dma_buf files live on an internal kernel mount (`dma_buf_mnt`,
2567 // created with `kern_mount`) whose superblock draws its device
2568 // number from `get_anon_bdev()` — an IDA allocated first-come,
2569 // first-served during boot, shared with every other anonymous
2570 // pseudo-filesystem (pipefs, sockfs, anon_inodefs, nsfs, bdev,
2571 // tracefs, ...). The minor a DMA-BUF lands on is therefore a
2572 // function of which pseudo-filesystems registered first on that
2573 // kernel build: it moves with kernel config, driver load order,
2574 // initramfs use, and kernel version. It is not part of any ABI.
2575 //
2576 // Observed values for a genuine DMA-BUF: 12 on x86 desktop, 8 on
2577 // the ADIS Verdin. An earlier revision hardcoded `9 | 10` here,
2578 // which silently imported real DMA-BUFs as shared memory
2579 // everywhere else.
2580 //
2581 // The filesystem magic, by contrast, is stable UAPI
2582 // (include/uapi/linux/magic.h). Both branches are identified
2583 // positively; anything else is genuinely unknown and is rejected
2584 // rather than guessed at. Falling back to SHM would "work" — a
2585 // DMA-BUF is mmap-able, and even a pipe imports as a zero-length
2586 // tensor — which is exactly why it must not be the default.
2587 let magic = fs_magic(fstatfs(&fd)?.filesystem_type().0 as i64);
2588
2589 log::debug!(
2590 "Creating tensor from fd: major={major}, minor={minor}, magic={magic:#010x}"
2591 );
2592
2593 match magic {
2594 DMA_BUF_MAGIC => DmaTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Dma),
2595 TMPFS_MAGIC => ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm),
2596 other => Err(Error::UnknownBufferType(other)),
2597 }
2598 }
2599 #[cfg(all(unix, not(target_os = "linux")))]
2600 {
2601 // On macOS/iOS/BSD, always use SHM (no DMA-BUF fd import)
2602 ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm)
2603 }
2604 }
2605}
2606
2607#[cfg(feature = "static")]
2608impl<T> TensorTrait<T> for TensorStorage<T>
2609where
2610 T: Num + Clone + fmt::Debug + Send + Sync,
2611{
2612 fn new(shape: &[usize], name: Option<&str>) -> Result<Self> {
2613 Self::new(shape, None, name)
2614 }
2615
2616 #[cfg(unix)]
2617 fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2618 Self::from_fd(fd, shape, name)
2619 }
2620
2621 #[cfg(unix)]
2622 fn clone_fd(&self) -> Result<OwnedFd> {
2623 match self {
2624 #[cfg(any(
2625 target_os = "linux",
2626 target_os = "macos",
2627 target_os = "ios",
2628 target_os = "android"
2629 ))]
2630 TensorStorage::Dma(t) => t.clone_fd(),
2631 TensorStorage::Shm(t) => t.clone_fd(),
2632 TensorStorage::Mem(t) => t.clone_fd(),
2633 TensorStorage::Pbo(t) => t.clone_fd(),
2634 }
2635 }
2636
2637 fn memory(&self) -> TensorMemory {
2638 match self {
2639 #[cfg(any(
2640 target_os = "linux",
2641 target_os = "macos",
2642 target_os = "ios",
2643 target_os = "android",
2644 target_os = "windows"
2645 ))]
2646 TensorStorage::Dma(_) => TensorMemory::DmaBuf,
2647 #[cfg(unix)]
2648 TensorStorage::Shm(_) => TensorMemory::Shm,
2649 TensorStorage::Mem(_) => TensorMemory::Mem,
2650 TensorStorage::Pbo(_) => TensorMemory::Pbo,
2651 }
2652 }
2653
2654 fn name(&self) -> String {
2655 match self {
2656 #[cfg(any(
2657 target_os = "linux",
2658 target_os = "macos",
2659 target_os = "ios",
2660 target_os = "android",
2661 target_os = "windows"
2662 ))]
2663 TensorStorage::Dma(t) => t.name(),
2664 #[cfg(unix)]
2665 TensorStorage::Shm(t) => t.name(),
2666 TensorStorage::Mem(t) => t.name(),
2667 TensorStorage::Pbo(t) => t.name(),
2668 }
2669 }
2670
2671 fn shape(&self) -> &[usize] {
2672 match self {
2673 #[cfg(any(
2674 target_os = "linux",
2675 target_os = "macos",
2676 target_os = "ios",
2677 target_os = "android",
2678 target_os = "windows"
2679 ))]
2680 TensorStorage::Dma(t) => t.shape(),
2681 #[cfg(unix)]
2682 TensorStorage::Shm(t) => t.shape(),
2683 TensorStorage::Mem(t) => t.shape(),
2684 TensorStorage::Pbo(t) => t.shape(),
2685 }
2686 }
2687
2688 fn reshape(&mut self, shape: &[usize]) -> Result<()> {
2689 match self {
2690 #[cfg(any(
2691 target_os = "linux",
2692 target_os = "macos",
2693 target_os = "ios",
2694 target_os = "android",
2695 target_os = "windows"
2696 ))]
2697 TensorStorage::Dma(t) => t.reshape(shape),
2698 #[cfg(unix)]
2699 TensorStorage::Shm(t) => t.reshape(shape),
2700 TensorStorage::Mem(t) => t.reshape(shape),
2701 TensorStorage::Pbo(t) => t.reshape(shape),
2702 }
2703 }
2704
2705 fn capacity_bytes(&self) -> usize {
2706 match self {
2707 #[cfg(any(
2708 target_os = "linux",
2709 target_os = "macos",
2710 target_os = "ios",
2711 target_os = "android",
2712 target_os = "windows"
2713 ))]
2714 TensorStorage::Dma(t) => t.capacity_bytes(),
2715 #[cfg(unix)]
2716 TensorStorage::Shm(t) => t.capacity_bytes(),
2717 TensorStorage::Mem(t) => t.capacity_bytes(),
2718 TensorStorage::Pbo(t) => t.capacity_bytes(),
2719 }
2720 }
2721
2722 fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
2723 match self {
2724 #[cfg(any(
2725 target_os = "linux",
2726 target_os = "macos",
2727 target_os = "ios",
2728 target_os = "android",
2729 target_os = "windows"
2730 ))]
2731 TensorStorage::Dma(t) => t.set_logical_shape(shape),
2732 #[cfg(unix)]
2733 TensorStorage::Shm(t) => t.set_logical_shape(shape),
2734 TensorStorage::Mem(t) => t.set_logical_shape(shape),
2735 TensorStorage::Pbo(t) => t.set_logical_shape(shape),
2736 }
2737 }
2738
2739 fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2740 where
2741 T: 'a,
2742 {
2743 match self {
2744 #[cfg(any(
2745 target_os = "linux",
2746 target_os = "macos",
2747 target_os = "ios",
2748 target_os = "android",
2749 target_os = "windows"
2750 ))]
2751 TensorStorage::Dma(t) => t.map_with(access),
2752 #[cfg(unix)]
2753 TensorStorage::Shm(t) => t.map_with(access),
2754 TensorStorage::Mem(t) => t.map_with(access),
2755 TensorStorage::Pbo(t) => t.map_with(access),
2756 }
2757 }
2758
2759 fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2760 where
2761 T: 'a,
2762 {
2763 match self {
2764 #[cfg(any(
2765 target_os = "linux",
2766 target_os = "macos",
2767 target_os = "ios",
2768 target_os = "android",
2769 target_os = "windows"
2770 ))]
2771 TensorStorage::Dma(t) => t.try_map_with(access),
2772 #[cfg(unix)]
2773 TensorStorage::Shm(t) => t.try_map_with(access),
2774 TensorStorage::Mem(t) => t.try_map_with(access),
2775 TensorStorage::Pbo(t) => t.try_map_with(access),
2776 }
2777 }
2778
2779 fn buffer_identity(&self) -> &BufferIdentity {
2780 match self {
2781 #[cfg(any(
2782 target_os = "linux",
2783 target_os = "macos",
2784 target_os = "ios",
2785 target_os = "android",
2786 target_os = "windows"
2787 ))]
2788 TensorStorage::Dma(t) => t.buffer_identity(),
2789 #[cfg(unix)]
2790 TensorStorage::Shm(t) => t.buffer_identity(),
2791 TensorStorage::Mem(t) => t.buffer_identity(),
2792 TensorStorage::Pbo(t) => t.buffer_identity(),
2793 }
2794 }
2795
2796 /// Forward a sub-region view to the active backend, re-wrapping the
2797 /// backend's view (which shares the parent's allocation and
2798 /// [`BufferIdentity`]) back into the matching `TensorStorage` variant. Each
2799 /// backend's `view` is its own `TensorTrait::view` override; this single
2800 /// match is the only per-variant dispatch (`Tensor::subview` calls through
2801 /// here rather than matching the storage itself).
2802 fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self> {
2803 match self {
2804 #[cfg(any(
2805 target_os = "linux",
2806 target_os = "macos",
2807 target_os = "ios",
2808 target_os = "android",
2809 target_os = "windows"
2810 ))]
2811 TensorStorage::Dma(t) => t.view(offset_bytes, shape).map(TensorStorage::Dma),
2812 #[cfg(unix)]
2813 TensorStorage::Shm(t) => t.view(offset_bytes, shape).map(TensorStorage::Shm),
2814 TensorStorage::Mem(t) => t.view(offset_bytes, shape).map(TensorStorage::Mem),
2815 TensorStorage::Pbo(t) => t.view(offset_bytes, shape).map(TensorStorage::Pbo),
2816 }
2817 }
2818}
2819
2820/// Multi-backend tensor with optional image format metadata.
2821///
2822/// When `format` is `Some`, this tensor represents an image. Width, height,
2823/// and channels are derived from `shape` + `format`. When `format` is `None`,
2824/// this is a raw tensor (identical to the pre-refactoring behavior).
2825///
2826/// This is the `static` backend's `Tensor<T>` -- it owns its storage. See
2827/// `tensor_dyn/dynamic_tensor.rs` for `dynamic`'s `Tensor<T>`, a typed lens
2828/// with no storage of its own.
2829#[cfg(feature = "static")]
2830#[derive(Debug)]
2831pub struct Tensor<T>
2832where
2833 T: Num + Clone + fmt::Debug + Send + Sync,
2834{
2835 /// CUDA registration for this tensor, if any. Set after creation by
2836 /// the image crate once a PBO is registered with CUDA interop.
2837 ///
2838 /// MUST be declared before `storage`: CUDA must unregister the GL buffer
2839 /// before storage's Drop deletes it (cudaGraphicsUnregisterResource before
2840 /// glDeleteBuffers). Rust drops fields in declaration order.
2841 cuda: Option<crate::cuda::CudaHandle>,
2842 pub(crate) storage: TensorStorage<T>,
2843 format: Option<PixelFormat>,
2844 chroma: Option<Box<Tensor<T>>>,
2845 /// Row stride in bytes for externally allocated buffers with row padding.
2846 /// `None` means tightly packed (stride == width * bytes_per_pixel).
2847 row_stride: Option<usize>,
2848 /// Byte offset within the DMA-BUF where image data starts.
2849 /// `None` means offset 0 (data starts at the beginning of the buffer).
2850 plane_offset: Option<usize>,
2851 /// Quantization metadata for integer-typed tensors. Public access is
2852 /// gated by the `IntegerType` trait — `Tensor<f32>` etc. carry the
2853 /// field for layout uniformity but have no way to read or write it.
2854 pub(crate) quantization: Option<Quantization>,
2855 /// Optional colorimetry metadata. `None` = undefined; never auto-filled.
2856 colorimetry: Option<crate::Colorimetry>,
2857 /// Declared CPU access (see [`CpuAccess`]). Image constructors set it
2858 /// from their `access` parameter; non-image tensors (`new`, imports,
2859 /// numpy) default to `ReadWrite` — they are CPU-centric by nature.
2860 /// `map_with` counts requests beyond this declaration as unplanned.
2861 cpu_access: CpuAccess,
2862 /// Recorded vendor tile-compression scheme — best knowledge from
2863 /// allocation time (see [`Compression`]). `Some` only for Android
2864 /// hardware-only AHardwareBuffers whose allocation requested
2865 /// compression on an eligible format with a recognized vendor. When
2866 /// set, the row-stride accessors describe no meaningful linear
2867 /// layout. `configure_image` preserves it (physical layout, unlike
2868 /// colorimetry); views inherit it.
2869 compression: Option<CompressionScheme>,
2870 /// Parent-image snapshot when this tensor is a [`view`](Self::view)/
2871 /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. Lets the GL
2872 /// backend key its import on the parent and render the view as a
2873 /// `glViewport`/`glScissor` ROI. See [`ViewOrigin`].
2874 view_origin: Option<ViewOrigin>,
2875}
2876
2877#[cfg(feature = "static")]
2878impl<T> Tensor<T>
2879where
2880 T: Num + Clone + fmt::Debug + Send + Sync,
2881{
2882 /// Wrap a TensorStorage in a Tensor with no image metadata.
2883 pub(crate) fn wrap(storage: TensorStorage<T>) -> Self {
2884 Self {
2885 storage,
2886 format: None,
2887 chroma: None,
2888 row_stride: None,
2889 plane_offset: None,
2890 quantization: None,
2891 cuda: None,
2892 colorimetry: None,
2893 cpu_access: CpuAccess::ReadWrite,
2894 compression: None,
2895 view_origin: None,
2896 }
2897 }
2898
2899 /// Construct a tensor from a row-major element slice + shape. Allocates a
2900 /// new buffer (`TensorMemory::Mem`) and memcpys the contents; caller
2901 /// retains ownership of the input slice.
2902 ///
2903 /// # Errors
2904 ///
2905 /// - [`Error::InvalidShape`] if `values.len() != shape.iter().product()`.
2906 /// - Propagates any allocation error from [`Self::new`].
2907 pub fn from_slice(values: &[T], shape: &[usize]) -> Result<Self>
2908 where
2909 T: Copy,
2910 {
2911 let expected: usize = shape.iter().product();
2912 if values.len() != expected {
2913 return Err(Error::InvalidShape(format!(
2914 "from_slice: values.len()={} but shape product={expected} (shape={shape:?})",
2915 values.len()
2916 )));
2917 }
2918 let t = Self::new(shape, Some(TensorMemory::Mem), None)?;
2919 {
2920 let mut m = t.map()?;
2921 m.as_mut_slice().copy_from_slice(values);
2922 }
2923 Ok(t)
2924 }
2925
2926 /// Wrap externally-owned memory as a tensor without copying. The tensor
2927 /// borrows `[ptr, ptr + shape.product() * size_of::<T>())` as
2928 /// [`TensorMemory::Mem`]; `owner`, when `Some`, co-owns the source so it
2929 /// outlives the tensor (and all derived views/maps). See [`ForeignOwner`].
2930 ///
2931 /// The canonical use is CUDA zero-copy: allocate host-coherent memory
2932 /// (`cudaHostAlloc`), wrap the host pointer here, and bind the matching
2933 /// device pointer to the inference engine — reads and writes hit the same
2934 /// physical buffer with no host copy. The identical primitive backs the
2935 /// Python `Tensor.from_numpy` zero-copy borrow (owner = the NumPy object).
2936 ///
2937 /// # Safety
2938 ///
2939 /// `ptr` must be non-null, aligned to `align_of::<T>()`, and valid for
2940 /// `shape.product()` elements of `T` for as long as the returned tensor —
2941 /// and every view/map sharing its backing — is alive. Pass an `owner` that
2942 /// co-owns the source to uphold that contract.
2943 ///
2944 /// # Errors
2945 ///
2946 /// [`Error::InvalidSize`] if `shape` is empty.
2947 pub unsafe fn from_foreign(
2948 ptr: *mut T,
2949 shape: &[usize],
2950 owner: Option<crate::ForeignOwner>,
2951 name: Option<&str>,
2952 ) -> Result<Self> {
2953 // SAFETY: forwarded to `from_foreign_with_capacity`'s contract; `0`
2954 // clamps to the tight shape-derived footprint below, i.e. no extra
2955 // headroom -- identical to this function's own prior behaviour.
2956 unsafe { Self::from_foreign_with_capacity(ptr, shape, 0, owner, name) }
2957 }
2958
2959 /// [`Self::from_foreign`], additionally recording that the allocation
2960 /// behind `ptr` holds at least `capacity_bytes` bytes -- headroom beyond
2961 /// the tight `shape.product() * size_of::<T>()` footprint that a later
2962 /// [`configure_image`](Self::configure_image) / `set_logical_shape` can
2963 /// grow into without erroring.
2964 ///
2965 /// This is the consumer half of the cross-package capsule protocol's
2966 /// [`crate::protocol::TensorDesc::capacity`]: a producer's pool tensor
2967 /// (allocated for its largest expected image, holding a smaller one
2968 /// today) reports its real allocation size so the imported alias
2969 /// inherits the same headroom instead of being clamped to today's
2970 /// logical shape. `capacity_bytes` below the tight footprint is clamped
2971 /// up to it, never down -- the shape-implied floor always holds.
2972 ///
2973 /// # Safety
2974 ///
2975 /// Same as [`Self::from_foreign`], except `ptr` must be valid for
2976 /// `capacity_bytes` bytes (or the tight shape footprint, whichever is
2977 /// larger), not just the shape-implied footprint.
2978 ///
2979 /// # Errors
2980 ///
2981 /// [`Error::InvalidSize`] if `shape` is empty.
2982 pub unsafe fn from_foreign_with_capacity(
2983 ptr: *mut T,
2984 shape: &[usize],
2985 capacity_bytes: usize,
2986 owner: Option<crate::ForeignOwner>,
2987 name: Option<&str>,
2988 ) -> Result<Self> {
2989 if shape.is_empty() {
2990 return Err(Error::InvalidSize(0));
2991 }
2992 if ptr.is_null() {
2993 return Err(Error::InvalidArgument(
2994 "from_foreign: ptr must be non-null".to_owned(),
2995 ));
2996 }
2997 shape
2998 .iter()
2999 .copied()
3000 .try_fold(1usize, |acc, dim| acc.checked_mul(dim))
3001 .ok_or_else(|| {
3002 Error::InvalidArgument(format!(
3003 "from_foreign: shape.product() overflows usize (shape={shape:?})"
3004 ))
3005 })?;
3006 let capacity_elems = capacity_bytes / std::mem::size_of::<T>();
3007 // SAFETY: this function's own contract (see its doc comment) is
3008 // exactly `MemTensor::from_foreign_with_capacity`'s contract.
3009 let mem = unsafe {
3010 MemTensor::<T>::from_foreign_with_capacity(ptr, shape, capacity_elems, owner, name)
3011 };
3012 Ok(Self::wrap(TensorStorage::Mem(mem)))
3013 }
3014
3015 /// Pin a stable host address for this tensor's data.
3016 ///
3017 /// The returned [`HostPin`] carries **no borrow of the tensor**, so a
3018 /// caller can pin once at init and still pass `&mut self` to
3019 /// `ImageProcessor::convert` every frame. That borrow conflict is the
3020 /// blocker reported in [#134](https://github.com/EdgeFirstAI/hal/issues/134).
3021 ///
3022 /// The address stays valid until the pin is dropped, and survives
3023 /// re-layout: `configure_image` and `set_logical_shape` only rewrite the
3024 /// logical shape after a capacity check, and every backend's pin owns its
3025 /// own mapping, so nothing can move underneath it.
3026 ///
3027 /// A pin is therefore a **snapshot**, not a tracker: [`HostPin::len`]
3028 /// reports the extent at pin time and does not follow a later re-layout.
3029 /// That is the safe direction — the window always stays inside the
3030 /// allocation — but it means a caller who re-layouts should re-pin if it
3031 /// wants the new extent.
3032 ///
3033 /// The address is **not** a coherency guarantee: on non-coherent backends
3034 /// bracket CPU access with
3035 /// [`sync_for_cpu`](Self::sync_for_cpu) and
3036 /// [`sync_for_device`](Self::sync_for_device), which are no-ops where the
3037 /// backend does not require them.
3038 ///
3039 /// # Cost
3040 ///
3041 /// On `Mem` this is an `Arc` refcount bump — the backing allocation is
3042 /// never resized, so its base pointer is already stable. Other backends
3043 /// establish a mapping once and reuse it.
3044 ///
3045 /// # Errors
3046 ///
3047 /// - [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`]: a
3048 /// pinned mapping is CPU access by definition, so this is an error
3049 /// rather than a warn-and-stage.
3050 /// - [`Error::NotImplemented`] for the two backends that **cannot** pin,
3051 /// rather than merely not having been wired yet: a PBO
3052 /// (`glMapBufferRange` establishes the address and the visibility
3053 /// together, and there is no `GL_MAP_PERSISTENT_BIT` in GLES) and an
3054 /// AHardwareBuffer (`AHardwareBuffer_lock` is what yields the address at
3055 /// all, so there is no address outside the lock). Both name
3056 /// `map()`/`map_with()` as the alternative. `Mem`, `Shm`, DMA-BUF,
3057 /// IOSurface and the Windows D3D11 texture all pin — the texture through
3058 /// a persistent host buffer the storage owns, which
3059 /// [`sync_for_cpu`](Self::sync_for_cpu) and
3060 /// [`sync_for_device`](Self::sync_for_device) move bytes between and the
3061 /// texture, rather than through the staging mapping (a pin outlives every
3062 /// map guard, so holding the staging texture mapped would freeze it).
3063 pub fn pin_host<'a>(&self, access: CpuAccess) -> Result<HostPin<'a>>
3064 where
3065 T: 'a,
3066 {
3067 if access == CpuAccess::None {
3068 return Err(Error::InvalidArgument(
3069 "pin_host: CpuAccess::None is not a valid pin; a pinned mapping \
3070 is CPU access by definition"
3071 .to_owned(),
3072 ));
3073 }
3074 // Backends yield everything addressable from the tensor's offset;
3075 // narrow to the logical extent so a consumer cannot mistake pitch
3076 // padding for data. A map guard keeps the wider window.
3077 let logical = self.len() * std::mem::size_of::<T>();
3078 let pin = match &self.storage {
3079 TensorStorage::Mem(m) => Ok(m.host_pin()),
3080 #[cfg(unix)]
3081 TensorStorage::Shm(s) => s.host_pin(),
3082 #[cfg(target_os = "linux")]
3083 TensorStorage::Dma(d) => d.host_pin(),
3084 #[cfg(any(target_os = "macos", target_os = "ios"))]
3085 TensorStorage::Dma(s) => s.host_pin(),
3086 // Windows D3D11: the pin is the storage's own persistent host
3087 // buffer, not the staging mapping -- see `D3d11TextureTensor::host_pin`.
3088 // The direction goes with it: a writable pin is what tells
3089 // `sync_for_device` the host buffer, not the staging copy, carries
3090 // the caller's writes.
3091 #[cfg(target_os = "windows")]
3092 TensorStorage::Dma(t) => t.host_pin(access),
3093 // AHardwareBuffer cannot support a pin at all, and that is a
3094 // platform contract rather than missing work: the NDK exposes no
3095 // way to obtain a CPU address outside AHardwareBuffer_lock, so a
3096 // "persistent" address would mean holding the lock for the pin's
3097 // lifetime -- which blocks the GPU from the buffer and makes any
3098 // further lock undefined. Both are the opposite of what a pin is
3099 // for. Say so, rather than implying a TODO.
3100 #[cfg(target_os = "android")]
3101 TensorStorage::Dma(_) => Err(Error::NotImplemented(
3102 "pin_host: AHardwareBuffer has no host address outside \
3103 AHardwareBuffer_lock, so it cannot hand out one that outlives \
3104 a guard. Use map()/map_with() for CPU access; for zero-copy \
3105 handoff pass the buffer itself."
3106 .to_owned(),
3107 )),
3108 // PBO is the same shape of constraint as AHardwareBuffer. The
3109 // message is a shared constant because `TensorDyn::pin_host`
3110 // (`tensor_dyn/dynamic_backend.rs`) must repeat this refusal --
3111 // the ABI has `ef_tensor_map` and no `ef_tensor_pin_host`, so the
3112 // library cannot tell a pin from a map. See
3113 // `crate::pbo::PIN_HOST_PBO_REFUSAL` for the reasoning at length.
3114 TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3115 crate::pbo::PIN_HOST_PBO_REFUSAL.to_owned(),
3116 )),
3117 }?;
3118 Ok(pin.narrowed(logical))
3119 }
3120
3121 /// Make CPU reads/writes of this tensor coherent — the CPU is about to
3122 /// access the buffer.
3123 ///
3124 /// Corresponds to `DMA_BUF_SYNC_START`, and the name matches the Linux
3125 /// kernel DMA API for the same reason: `for_cpu` says who is about to
3126 /// touch the memory, which is what the caller actually knows.
3127 ///
3128 /// Pair with [`sync_for_device`](Self::sync_for_device).
3129 ///
3130 /// # Which backends owe maintenance
3131 ///
3132 /// | backend | behaviour |
3133 /// |---|---|
3134 /// | `Mem`, `Shm` | **no-op** — plain CPU memory, coherent by construction |
3135 /// | `Dma` (Linux DMA-BUF) | one `DMA_BUF_IOCTL_SYNC`, direction-tagged |
3136 /// | `Dma` (macOS/iOS IOSurface) | `IOSurfaceLock` / `IOSurfaceUnlock`, options matched to `access` |
3137 /// | `Dma` (Android AHardwareBuffer) | [`Error::NotImplemented`] — no sync independent of the lock |
3138 /// | `Dma` (Windows D3D11 texture) | refreshes the pinned host buffer from the texture through the staging copy, or uploads it back; a no-op until something pins |
3139 /// | `Pbo` | [`Error::NotImplemented`] — no coherency window independent of the map |
3140 ///
3141 /// Only the first row is a no-op, so "bracket unconditionally and pay
3142 /// nothing" holds for `Mem`/`Shm` alone. The two that report
3143 /// `NotImplemented` do so because their platform fuses addressing and
3144 /// coherency into one call — `AHardwareBuffer_lock` and
3145 /// `glMapBufferRange` each perform the maintenance *as* they hand out the
3146 /// address, so a separate bracket would have to nest inside a map guard
3147 /// that already owns the pair. Both name `map()`/`map_with()` as the
3148 /// alternative. They previously fell through a wildcard and answered
3149 /// `Ok(())`, which is worse than an error because the symptom is
3150 /// stale-but-plausible pixels rather than a failure. The match is
3151 /// exhaustive so a new backend cannot inherit that silence — it must state
3152 /// its coherency story to compile.
3153 ///
3154 /// # Cost
3155 ///
3156 /// `Mem`/`Shm`: nothing. `Dma` on Linux: one ioctl. IOSurface: one
3157 /// lock/unlock pair. Windows D3D11 texture: one `CopyResource` plus a
3158 /// staging map per direction, and nothing at all on a tensor no one has
3159 /// pinned.
3160 ///
3161 /// # Errors
3162 ///
3163 /// [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`], which
3164 /// describes no access to make coherent.
3165 pub fn sync_for_cpu(&self, access: CpuAccess) -> Result<()> {
3166 Self::check_sync_access(access)?;
3167 match &self.storage {
3168 // Plain CPU memory: coherent by construction.
3169 TensorStorage::Mem(_) => Ok(()),
3170 #[cfg(unix)]
3171 TensorStorage::Shm(_) => Ok(()),
3172 #[cfg(target_os = "linux")]
3173 TensorStorage::Dma(d) => {
3174 // The direction tells the kernel which maintenance this access
3175 // needs: a read-only bracket skips the writeback at END, a
3176 // write-only one skips the invalidate at START. It MUST match
3177 // the direction passed to sync_for_device.
3178 crate::dmabuf::sync_access(&d.fd, true, access).map_err(Error::NixError)
3179 }
3180 #[cfg(any(target_os = "macos", target_os = "ios"))]
3181 TensorStorage::Dma(s) => s.sync_for_cpu(access),
3182 // Windows D3D11: refreshes the pinned host buffer from the texture
3183 // through the staging copy.
3184 #[cfg(target_os = "windows")]
3185 TensorStorage::Dma(t) => t.sync_for_cpu(access),
3186 #[cfg(target_os = "android")]
3187 TensorStorage::Dma(_) => Err(Error::NotImplemented(
3188 "sync_for_cpu: AHardwareBuffer has no sync independent of its \
3189 lock. AHardwareBuffer_lock both establishes the address and \
3190 performs the cache invalidate, and the NDK leaves locking an \
3191 already-locked buffer undefined, so a bracket on top of a map \
3192 guard would nest. Use map()/map_with(), which owns the pair."
3193 .to_owned(),
3194 )),
3195 TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3196 "sync_for_cpu: a PBO has no coherency window independent of its \
3197 map -- glMapBufferRange establishes the address and the \
3198 visibility together, and glUnmapBuffer publishes the writes. \
3199 Use map()/map_with(), which owns the pair."
3200 .to_owned(),
3201 )),
3202 }
3203 }
3204
3205 /// Release the buffer back to the device — the CPU is done accessing it.
3206 ///
3207 /// Corresponds to `DMA_BUF_SYNC_END`. See
3208 /// [`sync_for_cpu`](Self::sync_for_cpu) for the pairing and cost.
3209 pub fn sync_for_device(&self, access: CpuAccess) -> Result<()> {
3210 Self::check_sync_access(access)?;
3211 match &self.storage {
3212 TensorStorage::Mem(_) => Ok(()),
3213 #[cfg(unix)]
3214 TensorStorage::Shm(_) => Ok(()),
3215 #[cfg(target_os = "linux")]
3216 TensorStorage::Dma(d) => {
3217 crate::dmabuf::sync_access(&d.fd, false, access).map_err(Error::NixError)
3218 }
3219 #[cfg(any(target_os = "macos", target_os = "ios"))]
3220 TensorStorage::Dma(s) => s.sync_for_device(access),
3221 // Windows D3D11: uploads the pinned host buffer to the texture.
3222 #[cfg(target_os = "windows")]
3223 TensorStorage::Dma(t) => t.sync_for_device(access),
3224 #[cfg(target_os = "android")]
3225 TensorStorage::Dma(_) => Err(Error::NotImplemented(
3226 "sync_for_device: AHardwareBuffer has no sync independent of \
3227 its lock — see sync_for_cpu. Use map()/map_with()."
3228 .to_owned(),
3229 )),
3230 TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3231 "sync_for_device: a PBO has no coherency window independent of \
3232 its map -- see sync_for_cpu. Use map()/map_with()."
3233 .to_owned(),
3234 )),
3235 }
3236 }
3237
3238 fn check_sync_access(access: CpuAccess) -> Result<()> {
3239 if access == CpuAccess::None {
3240 return Err(Error::InvalidArgument(
3241 "sync_for_cpu/sync_for_device: CpuAccess::None describes no \
3242 access to synchronise"
3243 .to_owned(),
3244 ));
3245 }
3246 Ok(())
3247 }
3248
3249 /// Construct a tensor from a 3-D ndarray view. Respects strides — one
3250 /// copy in all cases; contiguous views take a memcpy fast path.
3251 ///
3252 /// Only available when the `ndarray` feature is enabled.
3253 #[cfg(feature = "ndarray")]
3254 pub fn from_arrayview3(view: ndarray::ArrayView3<'_, T>) -> Result<Self>
3255 where
3256 T: Copy,
3257 {
3258 let (h, w, c) = view.dim();
3259 let t = Self::new(&[h, w, c], Some(TensorMemory::Mem), None)?;
3260 {
3261 let mut m = t.map()?;
3262 let dst = m.as_mut_slice();
3263 if let Some(src) = view.as_slice() {
3264 dst.copy_from_slice(src);
3265 } else {
3266 for (d, &s) in dst.iter_mut().zip(view.iter()) {
3267 *d = s;
3268 }
3269 }
3270 }
3271 Ok(t)
3272 }
3273
3274 /// Create a new tensor with the given shape, memory type, and optional
3275 /// name. If no name is given, a random name will be generated. If no
3276 /// memory type is given, the best available memory type will be chosen
3277 /// based on the platform and environment variables.
3278 ///
3279 /// On Linux platforms, the order of preference is: Dma -> Shm -> Mem.
3280 /// On other Unix platforms (macOS), the order is: Shm -> Mem.
3281 /// On non-Unix platforms, only Mem is available.
3282 ///
3283 /// # Environment Variables
3284 /// - `EDGEFIRST_TENSOR_FORCE_MEM`: If set to a non-zero and non-false
3285 /// value, forces the use of regular system memory allocation
3286 /// (`TensorMemory::Mem`) regardless of platform capabilities.
3287 ///
3288 /// # Example
3289 /// ```rust
3290 /// use edgefirst_tensor::{Error, Tensor, TensorMemory, TensorTrait};
3291 /// # fn main() -> Result<(), Error> {
3292 /// let tensor = Tensor::<f32>::new(&[2, 3, 4], Some(TensorMemory::Mem), Some("test_tensor"))?;
3293 /// assert_eq!(tensor.memory(), TensorMemory::Mem);
3294 /// assert_eq!(tensor.name(), "test_tensor");
3295 /// # Ok(())
3296 /// # }
3297 /// ```
3298 pub fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
3299 let _span = tracing::trace_span!(
3300 "tensor.alloc",
3301 ?shape,
3302 memory = ?memory,
3303 dtype = std::any::type_name::<T>(),
3304 )
3305 .entered();
3306 #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
3307 let mut t = TensorStorage::new(shape, memory, name).map(Self::wrap)?;
3308 // Best-effort: attach a CUDA ExternalMemory handle for DMA tensors on
3309 // CUDA-capable hosts. Never blocks tensor creation on failure.
3310 // RUNTIME-UNVALIDATED: no CUDA+dma_heap test platform available; ABI
3311 // layout-asserted vs. CUDA 12.6 driver_types.h; mechanism proven by
3312 // gpu-probe O5 on Orin.
3313 #[cfg(target_os = "linux")]
3314 t.try_init_dma_cuda();
3315 Ok(t)
3316 }
3317
3318 /// Create an image tensor with the given format.
3319 /// Allocate an image tensor from a declarative request — the
3320 /// full-featured constructor behind [`Self::image`] and friends.
3321 ///
3322 /// Adds the [`Compression`] request to the classic parameters:
3323 ///
3324 /// - a request with any CPU access other than [`CpuAccess::None`] is
3325 /// [`Error::InvalidArgument`] (CPU mapping pins the layout linear);
3326 /// - [`Compression::Scheme`] fails with [`Error::NotImplemented`] on
3327 /// platforms without vendor tile compression, and with
3328 /// [`Error::InvalidArgument`] when the device's native scheme or
3329 /// the format eligibility doesn't match;
3330 /// - [`Compression::Any`] never fails for compression reasons: it
3331 /// records the scheme when the allocation is eligible and
3332 /// otherwise resolves linear, incrementing
3333 /// [`compression_fallback_count`].
3334 ///
3335 /// The recorded outcome is readable via [`Tensor::compression`].
3336 pub fn image_desc(desc: &ImageDesc) -> Result<Self>
3337 where
3338 T: 'static,
3339 {
3340 let Some(t_dtype) = dtype_of::<T>() else {
3341 return Err(Error::InvalidArgument(
3342 "image_desc: element type has no DType mapping".into(),
3343 ));
3344 };
3345 if t_dtype != desc.dtype {
3346 return Err(Error::InvalidArgument(format!(
3347 "image_desc: desc.dtype is {:?} but the tensor element type is {t_dtype:?}",
3348 desc.dtype
3349 )));
3350 }
3351
3352 // Compression-request guards. CPU access pins the layout linear,
3353 // so a request combined with any declared access is a
3354 // contradiction the caller should hear about immediately.
3355 if desc.compression.is_some() && desc.access != CpuAccess::None {
3356 return Err(Error::InvalidArgument(format!(
3357 "image_desc: a compression request requires CpuAccess::None (declared {:?}) — CPU mapping pins the layout linear",
3358 desc.access
3359 )));
3360 }
3361 if let Some(Compression::Scheme(requested)) = desc.compression {
3362 #[cfg(not(target_os = "android"))]
3363 {
3364 return Err(Error::NotImplemented(format!(
3365 "image_desc: Compression::Scheme({requested:?}) — no vendor tile compression on this platform (request Compression::Any for a portable fallback)"
3366 )));
3367 }
3368 #[cfg(target_os = "android")]
3369 {
3370 if matches!(
3371 desc.memory,
3372 Some(TensorMemory::Mem) | Some(TensorMemory::Shm) | Some(TensorMemory::Pbo)
3373 ) {
3374 return Err(Error::InvalidArgument(format!(
3375 "image_desc: Compression::Scheme({requested:?}) requires hardware memory (TensorMemory::DmaBuf or auto-select), got {:?}",
3376 desc.memory
3377 )));
3378 }
3379 if !crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype) {
3380 return Err(Error::InvalidArgument(format!(
3381 "image_desc: ({:?}, {:?}) is not compression-eligible (RGBA8888 u8/i8 initially)",
3382 desc.format, desc.dtype
3383 )));
3384 }
3385 let device = crate::ahardwarebuffer::device_compression_scheme();
3386 if device != Some(requested) {
3387 return Err(Error::InvalidArgument(format!(
3388 "image_desc: Compression::Scheme({requested:?}) but the device's native scheme is {device:?}"
3389 )));
3390 }
3391 }
3392 }
3393
3394 // A compression request implies a hardware pipeline, so auto
3395 // memory promotes to the platform's zero-copy allocation first
3396 // (`Tensor::image` only takes the AHardwareBuffer/IOSurface path
3397 // under an explicit Dma request). `Scheme` propagates the Dma
3398 // failure — the caller demanded a layout only that allocator can
3399 // produce; `Any` falls back to plain auto-select.
3400 #[allow(unused_mut)]
3401 let mut t = match (desc.memory, desc.compression) {
3402 (None, Some(request)) => {
3403 match Self::image(
3404 desc.width,
3405 desc.height,
3406 desc.format,
3407 Some(TensorMemory::DmaBuf),
3408 desc.access,
3409 ) {
3410 Ok(t) => t,
3411 Err(e) if matches!(request, Compression::Scheme(_)) => return Err(e),
3412 Err(_) => Self::image(desc.width, desc.height, desc.format, None, desc.access)?,
3413 }
3414 }
3415 (memory, _) => Self::image(desc.width, desc.height, desc.format, memory, desc.access)?,
3416 };
3417
3418 // Record best knowledge / count fallbacks. Only an Android
3419 // hardware-only AHardwareBuffer allocation can actually hold a
3420 // vendor tile layout; everywhere else an Any request resolves
3421 // linear and is counted.
3422 if let Some(request) = desc.compression {
3423 #[cfg(target_os = "android")]
3424 {
3425 let eligible =
3426 crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype);
3427 let scheme = crate::ahardwarebuffer::device_compression_scheme();
3428 let is_ahb = t.memory() == TensorMemory::DmaBuf;
3429 match (request, scheme) {
3430 (_, Some(s)) if eligible && is_ahb => {
3431 t.set_compression_unchecked(Some(s));
3432 }
3433 (Compression::Scheme(requested), _) => {
3434 // Pre-validated eligible + scheme match, so the only
3435 // way here is the allocation resolving off-AHB.
3436 return Err(Error::InvalidOperation(format!(
3437 "image_desc: Compression::Scheme({requested:?}) requested but the allocation resolved to {:?} (not an AHardwareBuffer)",
3438 t.memory()
3439 )));
3440 }
3441 (Compression::Any, _) => {
3442 note_compression_fallback(&format!(
3443 "({:?}, {:?}) {}x{}: eligible={eligible}, scheme={scheme:?}, memory={:?}",
3444 desc.format,
3445 desc.dtype,
3446 desc.width,
3447 desc.height,
3448 t.memory()
3449 ));
3450 }
3451 }
3452 }
3453 #[cfg(not(target_os = "android"))]
3454 {
3455 debug_assert!(matches!(request, Compression::Any));
3456 let _ = request;
3457 note_compression_fallback(&format!(
3458 "({:?}, {:?}) {}x{}: no vendor tile compression on this platform",
3459 desc.format, desc.dtype, desc.width, desc.height
3460 ));
3461 }
3462 }
3463 Ok(t)
3464 }
3465
3466 /// Allocate an image tensor of `width` × `height` in `format`.
3467 ///
3468 /// This is the common-case image constructor. The shape follows from the
3469 /// format's layout — packed `[H, W, C]`, planar `[C, H, W]`, semi-planar
3470 /// `[H + chroma_rows, W]` — and the format is recorded on the tensor, so
3471 /// the image crate and the codec can work from it without out-of-band
3472 /// parameters.
3473 ///
3474 /// `memory` requests a backend, or `None` to auto-select. Auto-select for
3475 /// images is **DMA → Mem**: unlike [`Tensor::new`] it does not try SHM,
3476 /// which offers an image nothing that Mem doesn't (it is not
3477 /// GPU-importable, and Mem always succeeds). Ask for
3478 /// [`TensorMemory::Shm`] explicitly if you need it.
3479 ///
3480 /// `access` declares what the **CPU** intends to do with the buffer and is
3481 /// required; see [`CpuAccess`]. Hardware access — GPU, NPU, ISP, codec —
3482 /// is always implied and never declared. Prefer the narrowest declaration
3483 /// that fits: `None` keeps the allocation eligible for Android vendor tile
3484 /// compression, and anything else pins the layout linear.
3485 ///
3486 /// DMA-backed images always get a **64-byte-aligned row stride**, in every
3487 /// layout, because Mali and Vivante reject an EGLImage import at an
3488 /// unaligned pitch. Read the real pitch back with
3489 /// [`Tensor::effective_row_stride`] rather than assuming `width × bpp`.
3490 ///
3491 /// For a compression request or any other less common knob, build an
3492 /// [`ImageDesc`] and call [`Tensor::image_desc`].
3493 ///
3494 /// # Errors
3495 ///
3496 /// - [`Error::InvalidArgument`] if `width` × `height` is not a valid size
3497 /// for `format` (for example odd dimensions where the format's chroma
3498 /// subsampling forbids them), or, on macOS with an explicit
3499 /// [`TensorMemory::DmaBuf`], if the format cannot be expressed as an
3500 /// image-formatted IOSurface at this width. The message names the
3501 /// aligned width to use.
3502 /// - Whatever the chosen backend returns if the allocation itself fails —
3503 /// for an explicit `memory` request there is no fallback.
3504 ///
3505 /// # Example
3506 ///
3507 /// ```rust
3508 /// use edgefirst_tensor::{CpuAccess, PixelFormat, Tensor, TensorMemory};
3509 ///
3510 /// # fn main() -> Result<(), edgefirst_tensor::Error> {
3511 /// // A camera frame the GPU converts and the CPU never touches.
3512 /// let frame = Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12,
3513 /// Some(TensorMemory::Mem), CpuAccess::None)?;
3514 /// assert_eq!(frame.format(), Some(PixelFormat::Nv12));
3515 /// # Ok(())
3516 /// # }
3517 /// ```
3518 pub fn image(
3519 width: usize,
3520 height: usize,
3521 format: PixelFormat,
3522 memory: Option<TensorMemory>,
3523 access: CpuAccess,
3524 ) -> Result<Self>
3525 where
3526 T: 'static,
3527 {
3528 // Shape comes from the shared `PixelFormat::allocation_shape` helper (packed /
3529 // planar / semi-planar NV12·NV16). NV12 supports odd dimensions via the
3530 // `H + ceil(H/2)` combined-plane height.
3531 // The `T: 'static` bound is required by the macOS IOSurface path below.
3532 let shape = format.allocation_shape(width, height).ok_or_else(|| {
3533 Error::InvalidArgument(format!(
3534 "invalid dimensions {width}x{height} for format {format:?}"
3535 ))
3536 })?;
3537
3538 // macOS Dma path: allocate a format-aware IOSurface (FourCC +
3539 // 2D dimensions) so the GL backend can bind it via
3540 // `EGL_ANGLE_iosurface_client_buffer`. Without this, the IOSurface
3541 // would default to a generic byte buffer (FourCC 'L008') and
3542 // ANGLE would reject the import with `EGL_BAD_ATTRIBUTE`.
3543 //
3544 // Guard: IOSurface rounds `bytes_per_row` up to 64-byte alignment.
3545 // If the natural row pitch (`width * channels * sizeof(T)`) is not
3546 // already 64-byte aligned, the padded allocation cannot be mapped
3547 // as a contiguous packed tensor — CPU reads/writes would use the
3548 // wrong stride.
3549 //
3550 // Explicit-Dma contract: when the caller passes
3551 // `Some(TensorMemory::DmaBuf)` they have asked for an
3552 // **image-formatted IOSurface**. Silently downgrading to the
3553 // generic 'L008' byte-bag when alignment fails buries the
3554 // mismatch — the caller only finds out hours later when ANGLE
3555 // (or any GL importer) rejects the bind with
3556 // `EGL_BAD_ATTRIBUTE`. Same anti-pattern bit us previously on
3557 // Mali GPUs with DMA-BUF padding. The right behaviour is to
3558 // fail loudly here with the alignment requirement spelled out
3559 // so the caller can either pick aligned dimensions, request
3560 // SHM/Mem explicitly, or pass `memory=None` for auto-select.
3561 #[cfg(any(target_os = "macos", target_os = "ios"))]
3562 if matches!(memory, Some(TensorMemory::DmaBuf)) {
3563 // For planar formats the IOSurface stacks channels
3564 // vertically (channels * height rows), so the row stride is
3565 // single-channel width * sizeof(T). Packed formats keep the
3566 // natural width * channels * sizeof(T) stride.
3567 let natural_row_bytes = match format.layout() {
3568 PixelLayout::Planar => width * std::mem::size_of::<T>(),
3569 _ => width * format.channels() * std::mem::size_of::<T>(),
3570 };
3571 // A format with a real IOSurface FourCC (RGBA/BGRA/YUYV packed,
3572 // GREY/NV12/NV16/NV24 as R8) tolerates a non-64-aligned natural
3573 // pitch: the surface is allocated with its own 64-aligned
3574 // `bytes_per_row`, the tensor records that stride below, and a CPU
3575 // map iterates rows correctly via the strided-map path while the GL
3576 // import uses the surface's pitch directly — fully zero-copy.
3577 // Planar (the F16 RGBA16F packing) is consumed flat as
3578 // `[1, C, H, W]` with no stride, so it still requires an aligned
3579 // pitch; and formats without a FourCC would fall through to a
3580 // generic byte-bag GL can't bind. Both fail loudly rather than
3581 // silently downgrade.
3582 let has_image_fourcc = dtype_of::<T>()
3583 .and_then(|dt| crate::iosurface_layout::image_iosurface_layout(format, dt))
3584 .is_some();
3585 let padded_ok = has_image_fourcc && format.layout() != PixelLayout::Planar;
3586 if !natural_row_bytes.is_multiple_of(64) && !padded_ok {
3587 let elem_size = std::mem::size_of::<T>();
3588 let per_pixel_bytes = match format.layout() {
3589 PixelLayout::Planar => elem_size.max(1),
3590 _ => format.channels().max(1) * elem_size.max(1),
3591 };
3592 // Compute the next 64-byte-aligned width by rounding the
3593 // natural row pitch up to the next multiple of 64 and
3594 // dividing back by per-pixel bytes. This handles every
3595 // `per_pixel_bytes` value correctly:
3596 //
3597 // * Divisors of 64 (1/2/4/8/16/32/64) → the suggestion
3598 // is always 64-byte aligned.
3599 // * Non-divisors of 64 (e.g. RGB u8 with 3 B/pixel) →
3600 // the next aligned row pitch may not be an integer
3601 // multiple of per_pixel_bytes (3 doesn't divide 64
3602 // in any way), so a "pad width to N" suggestion is
3603 // structurally impossible — omit the suggestion
3604 // instead of printing a wrong number.
3605 // * per_pixel_bytes > 64 → same situation, also
3606 // omitted; the previous formula divided by zero.
3607 //
3608 // The error always names the alignment requirement
3609 // verbatim and lists the two non-DMA alternatives so
3610 // the caller has at least one always-applicable fix.
3611 let aligned_row_bytes = natural_row_bytes.next_multiple_of(64);
3612 let pad_hint =
3613 if per_pixel_bytes > 0 && aligned_row_bytes.is_multiple_of(per_pixel_bytes) {
3614 let w = aligned_row_bytes / per_pixel_bytes;
3615 format!("Pad width to {w} (the next 64-byte-aligned stride), ")
3616 } else {
3617 String::new()
3618 };
3619 return Err(Error::InvalidArgument(format!(
3620 "Tensor::image: {format:?} {width}x{height} with element \
3621 size {elem_size} produces a {natural_row_bytes}-byte natural \
3622 row pitch, which is not 64-byte aligned. \
3623 IOSurface rounds bytes_per_row up to 64 bytes, so a \
3624 contiguous CPU map of this tensor would read garbage. \
3625 {pad_hint}pass memory=None to auto-fall-back to SHM, or \
3626 pass memory=Some(TensorMemory::Shm) or \
3627 Some(TensorMemory::Mem) explicitly."
3628 )));
3629 }
3630 // Alignment OK. Explicit-Dma contract: the caller asked for an
3631 // **image-formatted, GL-importable** IOSurface, so every failure
3632 // from here on is loud. The old behaviour fell through to the
3633 // generic 'L008' byte-bag `Some(other)` arm below, and the caller
3634 // only found out when the GL import rejected the bind with
3635 // `EGL_BAD_ATTRIBUTE` — the same silent-downgrade anti-pattern as
3636 // the alignment case above. (Semi-planar/Grey u8 map to 'L008'
3637 // *by design* in `image_iosurface_layout` — the R8-plane
3638 // representation the YUV shaders sample — so they return through
3639 // the mapped path and never reach these errors.)
3640 let dtype = dtype_of::<T>().ok_or_else(|| {
3641 Error::InvalidArgument(format!(
3642 "Tensor::image: element type {} has no DType, so no \
3643 image-formatted IOSurface exists. Pass memory=None or \
3644 Some(TensorMemory::Mem) for a CPU tensor.",
3645 std::any::type_name::<T>()
3646 ))
3647 })?;
3648 if crate::iosurface_layout::image_iosurface_layout(format, dtype).is_none() {
3649 return Err(Error::InvalidArgument(format!(
3650 "Tensor::image: no zero-copy IOSurface mapping exists for \
3651 {format:?}/{dtype:?} on macOS/iOS (supported: \
3652 Rgba/Rgb @ U8/I8, Bgra/Yuyv/Grey/Nv12/Nv16/Nv24 @ U8, \
3653 Rgba/PlanarRgb/PlanarRgba @ F16). Pass memory=None to \
3654 auto-select, or Some(TensorMemory::Mem) explicitly, for \
3655 a CPU tensor."
3656 )));
3657 }
3658 // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3659 // reject a width the texel packing cannot express up front
3660 // (mirrors the Android pre-guard).
3661 if format == PixelFormat::Rgb
3662 && matches!(dtype, DType::U8 | DType::I8)
3663 && packed_rgb888_layout(width, height).is_none()
3664 {
3665 return Err(Error::InvalidArgument(format!(
3666 "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3667 IOSurface packing (got width={width}). Pad the width, or pass \
3668 memory=Some(TensorMemory::Mem) for a CPU tensor."
3669 )));
3670 }
3671 let storage = TensorStorage::<T>::new_image_iosurface(
3672 width, height, format, dtype, &shape, None,
3673 )?;
3674 let mut t = Self::wrap(storage);
3675 t.format = Some(format);
3676 // IOSurface rounds `bytes_per_row` up to 64 bytes. When that
3677 // pitch exceeds the natural packed/planar row stride, record
3678 // it so CPU consumers iterate rows correctly (the GL import
3679 // already uses the surface's own pitch). For 64-aligned rows
3680 // — the common model-input case — the two match and no stride
3681 // is stored, leaving the flat mapping unchanged.
3682 if let TensorStorage::Dma(ref io) = t.storage {
3683 let bpr = io.bytes_per_row();
3684 if let Some(natural) = t.effective_row_stride() {
3685 if bpr > natural {
3686 t.set_row_stride_unchecked(bpr);
3687 }
3688 }
3689 }
3690 t.cpu_access = access;
3691 return Ok(t);
3692 }
3693
3694 // Android Dma path: allocate a format-aware AHardwareBuffer so the
3695 // GL backend can import it as an EGLImage
3696 // (`eglGetNativeClientBufferANDROID` → `eglCreateImageKHR`).
3697 //
3698 // Geometry: gralloc chooses the row pitch (`desc.stride`) at
3699 // allocation, and pads freely (validated on the Galaxy S26 Ultra,
3700 // where SnapAlloc pads the planar-F16 RGBA16F surface). A padded
3701 // pitch is recorded on the tensor so CPU maps iterate rows via the
3702 // strided-map path; the GL render uses the buffer's own pitch
3703 // through the EGLImage either way, so the GPU path stays fully
3704 // zero-copy. Consumers needing a FLAT layout (the future NPU
3705 // handoff's `[1, C, H, W]` contract) must check `row_stride()` and
3706 // repack when set — flatness is a per-device property here, unlike
3707 // macOS where IOSurface's 64-BYTE alignment keeps model-sized F16
3708 // surfaces naturally flat.
3709 #[cfg(target_os = "android")]
3710 if matches!(memory, Some(TensorMemory::DmaBuf)) {
3711 // Explicit-Dma contract (mirrors the macOS block above): the
3712 // caller asked for an image-formatted, GL-importable
3713 // AHardwareBuffer, so an unmapped combination or a gralloc
3714 // refusal errors here instead of falling through to a BLOB
3715 // byte-bag the GL backend can never import — that downgrade
3716 // only surfaced as a silent per-frame CPU upload.
3717 let dtype = dtype_of::<T>().ok_or_else(|| {
3718 Error::InvalidArgument(format!(
3719 "Tensor::image: element type {} has no DType, so no \
3720 image-formatted AHardwareBuffer exists. Pass memory=None \
3721 or Some(TensorMemory::Mem) for a CPU tensor.",
3722 std::any::type_name::<T>()
3723 ))
3724 })?;
3725 // Planar F16 requires width % 4 == 0 for the RGBA16F
3726 // packing — reject up front with the requirement spelled
3727 // out (mirrors the macOS pre-allocation guard) instead of
3728 // falling through to a byte-bag GL cannot bind.
3729 if format.layout() == PixelLayout::Planar
3730 && dtype == DType::F16
3731 && packed_rgba16f_layout(format, dtype, width, height).is_none()
3732 {
3733 return Err(Error::InvalidArgument(format!(
3734 "Tensor::image: {format:?} F16 requires width%4==0 for the RGBA16F \
3735 AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3736 memory=Some(TensorMemory::Mem) for a CPU tensor."
3737 )));
3738 }
3739 // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3740 // the same whole-texel constraint as the F16 packing above.
3741 if format == PixelFormat::Rgb
3742 && matches!(dtype, DType::U8 | DType::I8)
3743 && packed_rgb888_layout(width, height).is_none()
3744 {
3745 return Err(Error::InvalidArgument(format!(
3746 "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3747 AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3748 memory=Some(TensorMemory::Mem) for a CPU tensor."
3749 )));
3750 }
3751 if crate::ahardwarebuffer::image_ahardwarebuffer_layout(format, dtype).is_none() {
3752 return Err(Error::InvalidArgument(format!(
3753 "Tensor::image: no zero-copy AHardwareBuffer mapping exists \
3754 for {format:?}/{dtype:?} on Android (Grey/NV* need \
3755 R8-format buffers, API 29+; the HAL floor is API 26 — see \
3756 `image_ahardwarebuffer_layout`). Pass memory=None to \
3757 auto-select, or Some(TensorMemory::Mem) explicitly, for a \
3758 CPU tensor; camera NV12 stays zero-copy by wrapping the \
3759 camera's own AHardwareBuffer instead of allocating one."
3760 )));
3761 }
3762 let storage = TensorStorage::<T>::new_image_ahardwarebuffer(
3763 width, height, format, dtype, &shape, None, access,
3764 )?;
3765 let mut t = Self::wrap(storage);
3766 t.format = Some(format);
3767 if let TensorStorage::Dma(ref ahb) = t.storage {
3768 // gralloc chooses the row pitch (Qualcomm's
3769 // SnapAlloc pads e.g. the 160-px-wide RGBA16F
3770 // surface of a 640-wide planar F16 target).
3771 // When it exceeds the natural stride, record
3772 // it so CPU consumers iterate rows correctly
3773 // via `effective_row_stride()` — the GPU
3774 // renders through the EGLImage at the
3775 // buffer's real pitch regardless, so the
3776 // render stays fully zero-copy. Consumers
3777 // that need the FLAT `[1, C, H, W]` layout
3778 // (the future NPU handoff) must check
3779 // `row_stride()` is unset and repack — or
3780 // pick an aligned width — rather than assume
3781 // flatness (see the module docs).
3782 let bpr = ahb.bytes_per_row();
3783 if let Some(natural) = t.effective_row_stride() {
3784 if bpr > natural {
3785 log::debug!(
3786 "Tensor::image: gralloc padded the {format:?} \
3787 AHardwareBuffer pitch to {bpr} bytes (natural \
3788 {natural}); recording row stride"
3789 );
3790 t.set_row_stride_unchecked(bpr);
3791 }
3792 }
3793 }
3794 t.cpu_access = access;
3795 return Ok(t);
3796 }
3797
3798 // Windows Dma path: allocate an `ID3D11Texture2D` on the process
3799 // device so the GL backend can bind it through ANGLE's D3D11 texture
3800 // import and a D3D12/CUDA consumer can open its shared NT handle.
3801 //
3802 // Explicit-Dma contract, as on macOS and Android: the caller asked for
3803 // a texture, so a format with no texture layout is an error here
3804 // rather than a silent fall-through to the host-memory arms below,
3805 // which would only surface much later as a failed GPU import.
3806 #[cfg(target_os = "windows")]
3807 if matches!(memory, Some(TensorMemory::DmaBuf)) {
3808 let dtype = dtype_of::<T>().ok_or_else(|| {
3809 Error::InvalidArgument(format!(
3810 "Tensor::image: element type {} has no DType, so no D3D11 texture \
3811 layout exists. Pass memory=None or Some(TensorMemory::Mem) for a \
3812 CPU tensor.",
3813 std::any::type_name::<T>()
3814 ))
3815 })?;
3816 let storage = TensorStorage::<T>::new_image_d3d11(
3817 width, height, format, dtype, &shape, None, access,
3818 )?;
3819 let mut t = Self::wrap(storage);
3820 t.format = Some(format);
3821 record_d3d11_row_stride(&mut t, format, "Tensor::image");
3822 t.cpu_access = access;
3823 // Best-effort CUDA import, as the Linux DMA arms do: a texture a
3824 // CUDA consumer can map is worth the one import at allocation.
3825 t.try_init_d3d11_cuda();
3826 return Ok(t);
3827 }
3828
3829 // Compute the **64-byte-aligned** row stride for every image layout.
3830 //
3831 // Embedded GPUs reject `eglCreateImage` DMA-BUF imports whose row pitch
3832 // is not 64-byte aligned: Mali returns `EGL_BAD_ALLOC`, Vivante
3833 // `EGL_BAD_ACCESS`. This bit packed RGBA/RGB destinations at odd widths
3834 // AND at even non-multiple-of-16 widths (e.g. 321→1284, 322→1288 bytes —
3835 // neither divisible by 64), so an odd-source → RGBA convert failed on
3836 // imx95/imx8mp while succeeding on V3D/Tegra. Semi-planar already aligned
3837 // here; we now align packed and planar identically so every image()
3838 // allocation is GPU-importable regardless of width.
3839 //
3840 // The per-layout natural pitch and total row count:
3841 // * SemiPlanar `[total_h, width]` — pitch = even(width)·elem, rows = total_h
3842 // * Packed `[height, width, ch]` — pitch = width·ch·elem, rows = height
3843 // * Planar `[ch, height, width]` — pitch = width·elem, rows = ch·height
3844 // Allocation byte size = `aligned_stride · total_rows` (NOT the shape
3845 // product, which reflects only the logical width and under-allocates the
3846 // padding on odd / unaligned widths).
3847 let elem = std::mem::size_of::<T>();
3848 let channels = format.channels();
3849 let (natural_stride, total_rows) = match format.layout() {
3850 PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
3851 PixelLayout::Packed => (width * channels * elem, height),
3852 PixelLayout::Planar => (width * elem, channels * height),
3853 };
3854 let aligned_stride = natural_stride.next_multiple_of(64);
3855 let semi = format.layout() == PixelLayout::SemiPlanar;
3856
3857 // DMA buffers MUST carry a 64-aligned row pitch — Mali/Vivante reject a
3858 // DMA-BUF EGLImage whose pitch is not 64-aligned. Semi-planar also needs
3859 // the aligned pitch on every backend (its chroma-plane offset math
3860 // assumes it). Packed/planar on host-only memory (Mem/Shm) keep the
3861 // natural tight pitch so the many flat CPU consumers are unaffected.
3862 let host_stride = if semi { aligned_stride } else { natural_stride };
3863 let host_byte_size = host_stride * total_rows;
3864 #[cfg(target_os = "linux")]
3865 let dma_byte_size = aligned_stride * total_rows;
3866
3867 // `used_stride` is the actual row pitch of the storage created below.
3868 let (storage, used_stride) = match memory {
3869 #[cfg(target_os = "linux")]
3870 Some(TensorMemory::DmaBuf) => (
3871 TensorStorage::<T>::new_dma_with_byte_size(&shape, dma_byte_size, None)?,
3872 aligned_stride,
3873 ),
3874 #[cfg(unix)]
3875 Some(TensorMemory::Shm) => (
3876 TensorStorage::<T>::new_shm_with_byte_size(&shape, host_byte_size, None)?,
3877 host_stride,
3878 ),
3879 Some(TensorMemory::Mem) => (
3880 TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
3881 host_stride,
3882 ),
3883 #[allow(unused_variables)]
3884 Some(other) => {
3885 // PBO and any future variants: fall through to standard new().
3886 return {
3887 let mut t = Self::new(&shape, Some(other), None)?;
3888 t.format = Some(format);
3889 t.cpu_access = access;
3890 Ok(t)
3891 };
3892 }
3893 None => {
3894 // Auto-select priority: DMA → Mem (DMA gets the 64-aligned
3895 // pitch; the Mem fallback keeps the tight host pitch, so the
3896 // recorded stride matches the storage used). Shm is NOT
3897 // auto-selected — it offers no advantage over Mem for an
3898 // in-process image and Mem always succeeds, so it sits below Mem
3899 // and is reached only via an explicit `TensorMemory::Shm`.
3900 #[cfg(target_os = "linux")]
3901 {
3902 match TensorStorage::<T>::new_dma_with_byte_size(&shape, dma_byte_size, None) {
3903 Ok(s) => (s, aligned_stride),
3904 Err(_) => (
3905 TensorStorage::<T>::new_mem_with_byte_size(
3906 &shape,
3907 host_byte_size,
3908 None,
3909 )?,
3910 host_stride,
3911 ),
3912 }
3913 }
3914 #[cfg(not(target_os = "linux"))]
3915 {
3916 (
3917 TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
3918 host_stride,
3919 )
3920 }
3921 }
3922 };
3923
3924 let mut t = Self::wrap(storage);
3925 t.format = Some(format);
3926 // Record the row stride when it exceeds the natural tight pitch (padding
3927 // is present — DMA packed/planar at an unaligned width, or always for
3928 // semi-planar), mirroring the IOSurface path above. Aligned-width and
3929 // host-only packed/planar images keep their flat layout with no explicit
3930 // stride; `effective_row_stride()` then falls back to the identical
3931 // computed pitch. When padding IS present, consumers must iterate rows by
3932 // `effective_row_stride()` to skip it.
3933 if semi || used_stride > natural_stride {
3934 t.set_row_stride_unchecked(used_stride);
3935 }
3936 debug_assert!(
3937 t.row_stride.is_some() || !semi,
3938 "image() must always set row_stride for semi-planar tensors"
3939 );
3940 t.cpu_access = access;
3941 #[cfg(target_os = "linux")]
3942 t.try_init_dma_cuda();
3943 Ok(t)
3944 }
3945
3946 /// Create a DMA-backed image tensor with an explicit row stride that
3947 /// may exceed the natural `width * channels * sizeof(T)` pitch.
3948 ///
3949 /// Used for image tensors that need GPU pitch alignment padding: the
3950 /// underlying DMA-BUF is sized to `row_stride * height` bytes, but
3951 /// the tensor's logical shape stays at `[height, width, channels]`.
3952 /// `width()` / `height()` / `shape()` continue to report the
3953 /// user-requested values; the padding is visible only via
3954 /// `row_stride()` / `effective_row_stride()` and is automatically
3955 /// propagated to the GL backend's EGLImage import so Mali Valhall
3956 /// accepts the buffer.
3957 ///
3958 /// # Supported formats
3959 ///
3960 /// Currently only **packed** pixel layouts (RGBA8, BGRA8, RGB888,
3961 /// Grey, etc.) are supported — the formats the GL backend uses as
3962 /// render destinations. Semi-planar formats (NV12, NV16) come from
3963 /// external allocators (camera capture, video decoders) and are
3964 /// imported via `TensorDyn::from_fd` + `set_row_stride`, which
3965 /// already supports padded strides.
3966 ///
3967 /// # Supported memory
3968 ///
3969 /// Currently only `TensorMemory::DmaBuf` is supported. PBO and Mem
3970 /// storage don't go through EGLImage import so they don't need
3971 /// pitch alignment; if you pass any other memory type this returns
3972 /// `NotImplemented`. `None` (auto-select) is treated as `Dma`.
3973 ///
3974 /// # Errors
3975 ///
3976 /// - `InvalidArgument` if `row_stride_bytes < width * channels * sizeof(T)`
3977 /// (the requested stride would not fit a single row)
3978 /// - `NotImplemented` for non-packed formats or non-DMA memory
3979 /// - `IoError` if the DMA-heap allocation fails (propagated from
3980 /// `DmaTensor::new_with_byte_size`)
3981 pub fn image_with_stride(
3982 width: usize,
3983 height: usize,
3984 format: PixelFormat,
3985 row_stride_bytes: usize,
3986 memory: Option<TensorMemory>,
3987 access: CpuAccess,
3988 ) -> Result<Self> {
3989 #[cfg(not(target_os = "linux"))]
3990 let _ = access;
3991 // DMA backing (the only thing this constructor produces) is
3992 // Linux-only. On macOS/BSD/Windows the non-Linux block below is
3993 // the only compiled body and returns `NotImplemented` directly;
3994 // on Linux the non-Linux block is cfg-removed and the function
3995 // falls through to the real validation + allocation path. Each
3996 // target compiles exactly one of the two blocks, and the block
3997 // serves as the function's tail expression in both cases — so
3998 // neither needs an explicit `return` (avoids
3999 // `clippy::needless_return` on the macOS CI gate).
4000 #[cfg(not(target_os = "linux"))]
4001 {
4002 let _ = (width, height, format, row_stride_bytes, memory);
4003 Err(Error::NotImplemented(
4004 "image_with_stride requires DMA support (Linux only)".to_owned(),
4005 ))
4006 }
4007
4008 #[cfg(target_os = "linux")]
4009 {
4010 if format.layout() != PixelLayout::Packed {
4011 return Err(Error::NotImplemented(format!(
4012 "Tensor::image_with_stride only supports packed pixel layouts, got {format:?}"
4013 )));
4014 }
4015 let elem = std::mem::size_of::<T>();
4016 let min_stride = width
4017 .checked_mul(format.channels())
4018 .and_then(|p| p.checked_mul(elem))
4019 .ok_or_else(|| {
4020 Error::InvalidArgument(format!(
4021 "image_with_stride: width {width} × channels {} × sizeof::<T>={elem} \
4022 overflows usize",
4023 format.channels()
4024 ))
4025 })?;
4026 if row_stride_bytes < min_stride {
4027 return Err(Error::InvalidArgument(format!(
4028 "image_with_stride: row_stride {row_stride_bytes} < minimum {min_stride} \
4029 ({width} px × {} ch × {elem} B)",
4030 format.channels()
4031 )));
4032 }
4033 let total_byte_size = row_stride_bytes.checked_mul(height).ok_or_else(|| {
4034 Error::InvalidArgument(format!(
4035 "image_with_stride: row_stride {row_stride_bytes} × height {height} overflows usize"
4036 ))
4037 })?;
4038
4039 let shape = vec![height, width, format.channels()];
4040
4041 let storage = match memory {
4042 Some(TensorMemory::DmaBuf) | None => {
4043 TensorStorage::<T>::new_dma_with_byte_size(&shape, total_byte_size, None)?
4044 }
4045 Some(other) => {
4046 return Err(Error::NotImplemented(format!(
4047 "image_with_stride: only TensorMemory::DmaBuf is supported, got {other:?}"
4048 )));
4049 }
4050 };
4051
4052 let mut t = Self::wrap(storage);
4053 t.format = Some(format);
4054 t.row_stride = Some(row_stride_bytes);
4055 t.cpu_access = access;
4056 // Match new()/from_fd(): a DMA tensor must attempt CUDA external-
4057 // memory import so a strided DMA buffer is also zero-copy
4058 // CUDA-mappable (no-op when libcudart is absent).
4059 t.try_init_dma_cuda();
4060 Ok(t)
4061 }
4062 }
4063
4064 /// Attach format metadata to an existing tensor.
4065 ///
4066 /// # Arguments
4067 ///
4068 /// * `format` - The pixel format to attach
4069 ///
4070 /// # Returns
4071 ///
4072 /// `Ok(())` on success, with the format stored as metadata on the tensor.
4073 ///
4074 /// # Errors
4075 ///
4076 /// Returns `Error::InvalidShape` if the tensor shape is incompatible with
4077 /// the format's layout (packed expects `[H, W, C]`, planar expects
4078 /// `[C, H, W]`, semi-planar expects `[H*k, W]` with format-specific
4079 /// height constraints).
4080 pub fn set_format(&mut self, format: PixelFormat) -> Result<()> {
4081 let shape = self.shape();
4082 match format.layout() {
4083 PixelLayout::Packed => {
4084 if shape.len() != 3 || shape[2] != format.channels() {
4085 return Err(Error::InvalidShape(format!(
4086 "packed format {format:?} expects [H, W, {}], got {shape:?}",
4087 format.channels()
4088 )));
4089 }
4090 }
4091 PixelLayout::Planar => {
4092 if shape.len() != 3 || shape[0] != format.channels() {
4093 return Err(Error::InvalidShape(format!(
4094 "planar format {format:?} expects [{}, H, W], got {shape:?}",
4095 format.channels()
4096 )));
4097 }
4098 }
4099 PixelLayout::SemiPlanar => {
4100 if shape.len() != 2 {
4101 return Err(Error::InvalidShape(format!(
4102 "semi-planar format {format:?} expects [H*k, W], got {shape:?}"
4103 )));
4104 }
4105 match format {
4106 // Combined-plane height is `H + ceil(H/2)` (luma + chroma
4107 // rows). For even H that is `3H/2` (≡ 0 mod 3); for odd H it
4108 // is `(3H+1)/2` (≡ 2 mod 3). Only totals ≡ 1 mod 3 are
4109 // unreachable, so reject just those — odd-height NV12 is
4110 // valid (e.g. 725 rows for a 483-tall image).
4111 PixelFormat::Nv12 if shape[0] % 3 == 1 => {
4112 return Err(Error::InvalidShape(format!(
4113 "NV12 contiguous shape[0] must be H + ceil(H/2) for some height; \
4114 {} is unreachable (≡ 1 mod 3)",
4115 shape[0]
4116 )));
4117 }
4118 PixelFormat::Nv16 if !shape[0].is_multiple_of(2) => {
4119 return Err(Error::InvalidShape(format!(
4120 "NV16 contiguous shape[0] must be even, got {}",
4121 shape[0]
4122 )));
4123 }
4124 // NV24 (4:4:4): combined-plane height is 3H (Y + 2H chroma).
4125 PixelFormat::Nv24 if !shape[0].is_multiple_of(3) => {
4126 return Err(Error::InvalidShape(format!(
4127 "NV24 contiguous shape[0] must be a multiple of 3 (= 3H), got {}",
4128 shape[0]
4129 )));
4130 }
4131 _ => {}
4132 }
4133 }
4134 }
4135 // Clear stored stride/offset when format changes — they may be invalid
4136 // for the new format. Caller must re-set after changing format.
4137 if self.format != Some(format) {
4138 self.row_stride = None;
4139 self.plane_offset = None;
4140 match self.storage {
4141 TensorStorage::Mem(ref mut m) => m.set_offset(0),
4142 #[cfg(target_os = "linux")]
4143 TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
4144 // Paired with the arm in `set_plane_offset`: a setter that
4145 // takes effect on this backing needs a clear that does too,
4146 // or the wrapper reports `None` while `map()` still starts at
4147 // the old offset -- a stale window rather than a lost one.
4148 // `reshape`'s clear site needs no such arm:
4149 // `IoSurfaceTensor::reshape` zeroes `view_offset` itself
4150 // before the match there runs.
4151 #[cfg(any(target_os = "macos", target_os = "ios"))]
4152 TensorStorage::Dma(ref mut io) => io.view_offset = 0,
4153 // The same pair for D3D11. Unlike `IoSurfaceTensor`,
4154 // `D3d11TextureTensor::reshape` leaves `view_offset` alone, so
4155 // `reshape`'s clear site carries this arm as well.
4156 #[cfg(target_os = "windows")]
4157 TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
4158 // Paired with the arm in `set_plane_offset`: a setter that
4159 // takes effect on this backing needs a clear that does too.
4160 // `reshape`'s clear site needs no `Pbo` arm --
4161 // `PboTensor::reshape` zeroes `view_offset` itself before the
4162 // match there runs, exactly as `IoSurfaceTensor::reshape`
4163 // does. `set_logical_shape` deliberately does not, matching
4164 // `Mem`.
4165 TensorStorage::Pbo(ref mut p) => p.view_offset = 0,
4166 // Same pair, `Shm` side. `cfg(unix)` mirrors the `Shm`
4167 // variant's own gate on `TensorStorage`, not a per-OS
4168 // multiplex -- see the comment on the `set_plane_offset`
4169 // arm.
4170 #[cfg(unix)]
4171 TensorStorage::Shm(ref mut s) => s.set_offset(0),
4172 // No-op, pairing with `set_plane_offset`'s Android arm:
4173 // nothing writes `AHardwareBufferTensor::view_offset` there
4174 // either yet, so there is nothing here for a format change
4175 // to clear. See that arm's comment for the full accounting
4176 // of the still-incomplete gap.
4177 #[cfg(target_os = "android")]
4178 TensorStorage::Dma(_) => {}
4179 }
4180 }
4181 self.format = Some(format);
4182 Ok(())
4183 }
4184
4185 /// Set this tensor's logical dimensions and pixel format to a decoded
4186 /// image, reusing the existing allocation. The shape is derived from the
4187 /// format layout; fails with `Error::InsufficientCapacity` if the
4188 /// allocation cannot hold `width`×`height` in `format`, or
4189 /// `Error::InvalidArgument` if the dimensions are invalid for the format.
4190 ///
4191 /// For NV12/NV16/NV24 the buffer width is rounded up to even (a chroma-plane
4192 /// interleaving requirement); the true odd width is reported by the decoder
4193 /// in `ImageInfo` and trimmed by a `convert()` crop. See
4194 /// [`PixelFormat::allocation_shape`].
4195 ///
4196 /// When the backing has a fixed physical row pitch (an IOSurface's
4197 /// 64-aligned `bytesPerRow`) that exceeds the new format's natural row
4198 /// stride — i.e. a reused max-sized pool tensor reconfigured to a smaller
4199 /// image — the physical pitch is preserved as the tensor's `row_stride`.
4200 /// This keeps the **physical grid** (allocation stride/surface) fixed while
4201 /// the **logical ROI** (this image's W×H) changes, so the decode writes rows
4202 /// at the surface's real stride and the GPU samples them at the same stride.
4203 /// Exact-sized buffers (pitch == natural) stay tightly packed unchanged.
4204 pub fn configure_image(
4205 &mut self,
4206 width: usize,
4207 height: usize,
4208 format: PixelFormat,
4209 ) -> Result<()> {
4210 let shape = format.allocation_shape(width, height).ok_or_else(|| {
4211 Error::InvalidArgument(format!(
4212 "invalid dimensions {width}x{height} for format {format:?}"
4213 ))
4214 })?;
4215 // Capture the pre-existing row stride before `set_format` clears it.
4216 // For pool tensors that were allocated at a larger width (e.g. 1920-wide
4217 // pool decoding a 789-wide image), this preserves the backing pitch so
4218 // rows are still written at the correct physical stride.
4219 let prior_stride = self.row_stride;
4220
4221 self.storage.set_logical_shape(&shape)?;
4222 self.set_format(format)?; // clears any stale row_stride
4223
4224 // Restore the correct row pitch for the new geometry. A DMA buffer of
4225 // ANY layout, and a semi-planar buffer on any backing, MUST carry a
4226 // 64-byte-aligned pitch: Mali/Vivante reject a DMA-BUF EGLImage whose
4227 // row pitch is not 64-aligned (`EGL_BAD_ALLOC` / `EGL_BAD_ACCESS`), and
4228 // semi-planar chroma-offset math assumes it. This mirrors the pitch
4229 // `Tensor::image()` allocates, so a recycled pool buffer imports
4230 // identically to a fresh one — without it a `configure_image()`'d packed
4231 // buffer (e.g. Y800 96-wide → tight pitch 96) failed convert() on
4232 // imx95/imx8mp via the texture-upload fallback while the fresh oracle
4233 // (aligned 128) succeeded. Packed/planar on host-only memory (Mem/Shm)
4234 // keep the tight pitch so flat CPU consumers stay unaffected — matching
4235 // `image()`'s `host_stride` rule.
4236 let elem = std::mem::size_of::<T>();
4237 let channels = format.channels();
4238 let (min_stride, total_rows) = match format.layout() {
4239 PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
4240 PixelLayout::Packed => (width * channels * elem, height),
4241 PixelLayout::Planar => (width * elem, channels * height),
4242 };
4243 let needs_align = self.storage.memory() == TensorMemory::DmaBuf
4244 || format.layout() == PixelLayout::SemiPlanar;
4245
4246 let active_stride = if let Some(pitch) = self.storage.backing_row_stride() {
4247 // macOS IOSurface: use the surface's native pitch.
4248 let natural = self.effective_row_stride().unwrap_or(0);
4249 if pitch > natural {
4250 self.set_row_stride_unchecked(pitch);
4251 pitch
4252 } else {
4253 natural
4254 }
4255 } else if needs_align {
4256 // Priority:
4257 // 1. Prior stride (pool reuse): if the pre-existing stride is
4258 // 64-aligned, >= this layout's minimum row, and fits the
4259 // allocation, keep it. This is the hot-loop reuse case (large
4260 // pool, small image).
4261 // 2. Compute a fresh 64-aligned stride for the current width.
4262 let aligned = min_stride.next_multiple_of(64);
4263 let capacity = self.storage.capacity_bytes();
4264
4265 let candidate = if let Some(ps) = prior_stride {
4266 if ps >= min_stride && ps % 64 == 0 && ps * total_rows <= capacity {
4267 ps
4268 } else {
4269 aligned
4270 }
4271 } else {
4272 aligned
4273 };
4274
4275 if candidate * total_rows <= capacity {
4276 self.set_row_stride_unchecked(candidate);
4277 candidate
4278 } else {
4279 // Shouldn't happen for legitimate pools, but don't crash.
4280 self.effective_row_stride().unwrap_or(0)
4281 }
4282 } else {
4283 self.effective_row_stride().unwrap_or(0)
4284 };
4285
4286 // Ensure the active stride fits the allocation. A pool reconfigured to a
4287 // wider image than its backing would silently SIGBUS on any subsequent
4288 // map/write — catch it here instead.
4289 if needs_align && active_stride > 0 {
4290 let needed = active_stride * total_rows;
4291 let capacity = self.storage.capacity_bytes();
4292 if needed > capacity {
4293 return Err(Error::InsufficientCapacity { needed, capacity });
4294 }
4295 }
4296 Ok(())
4297 }
4298
4299 /// Allocate an image tensor sized to hold up to `width`×`height` in
4300 /// `format`, reusable for any smaller image via `configure_image`.
4301 pub fn image_with_capacity(
4302 width: usize,
4303 height: usize,
4304 format: PixelFormat,
4305 memory: Option<TensorMemory>,
4306 access: CpuAccess,
4307 ) -> Result<Self>
4308 where
4309 T: 'static,
4310 {
4311 Self::image(width, height, format, memory, access)
4312 }
4313
4314 /// Pixel format (None if not an image).
4315 pub fn format(&self) -> Option<PixelFormat> {
4316 self.format
4317 }
4318
4319 /// Image width (None if not an image).
4320 pub fn width(&self) -> Option<usize> {
4321 let fmt = self.format?;
4322 let shape = self.shape();
4323 match fmt.layout() {
4324 PixelLayout::Packed => Some(shape[1]),
4325 PixelLayout::Planar => Some(shape[2]),
4326 PixelLayout::SemiPlanar => Some(shape[1]),
4327 }
4328 }
4329
4330 /// Image height (None if not an image).
4331 ///
4332 /// For semi-planar formats the combined-plane shape row count is divided
4333 /// by the format's luma-to-total ratio to recover logical height. This
4334 /// returns the exact logical height (including odd heights) only because
4335 /// the logical dimensions are tracked separately from the physical shape —
4336 /// `configure_image` stores the actual `(width, height)` in the format's
4337 /// `allocation_shape`, which round-trips losslessly via these accessors.
4338 pub fn height(&self) -> Option<usize> {
4339 let fmt = self.format?;
4340 let shape = self.shape();
4341 match fmt.layout() {
4342 PixelLayout::Packed => Some(shape[0]),
4343 PixelLayout::Planar => Some(shape[1]),
4344 PixelLayout::SemiPlanar => {
4345 if self.is_multiplane() {
4346 Some(shape[0])
4347 } else {
4348 match fmt {
4349 PixelFormat::Nv12 => Some(shape[0] * 2 / 3),
4350 PixelFormat::Nv16 => Some(shape[0] / 2),
4351 PixelFormat::Nv24 => Some(shape[0] / 3),
4352 _ => None,
4353 }
4354 }
4355 }
4356 }
4357 }
4358
4359 /// Create from separate Y and UV planes (multiplane NV12/NV16).
4360 pub fn from_planes(luma: Tensor<T>, chroma: Tensor<T>, format: PixelFormat) -> Result<Self> {
4361 if format.layout() != PixelLayout::SemiPlanar {
4362 return Err(Error::InvalidArgument(format!(
4363 "from_planes requires a semi-planar format, got {format:?}"
4364 )));
4365 }
4366 if chroma.format.is_some() || chroma.chroma.is_some() {
4367 return Err(Error::InvalidArgument(
4368 "chroma tensor must be a raw tensor (no format or chroma metadata)".into(),
4369 ));
4370 }
4371 let luma_shape = luma.shape();
4372 let chroma_shape = chroma.shape();
4373 if luma_shape.len() != 2 || chroma_shape.len() != 2 {
4374 return Err(Error::InvalidArgument(format!(
4375 "from_planes expects 2D shapes, got luma={luma_shape:?} chroma={chroma_shape:?}"
4376 )));
4377 }
4378 if luma_shape[1] != chroma_shape[1] {
4379 return Err(Error::InvalidArgument(format!(
4380 "luma width {} != chroma width {}",
4381 luma_shape[1], chroma_shape[1]
4382 )));
4383 }
4384 match format {
4385 PixelFormat::Nv12 => {
4386 if luma_shape[0] % 2 != 0 {
4387 return Err(Error::InvalidArgument(format!(
4388 "NV12 requires even luma height, got {}",
4389 luma_shape[0]
4390 )));
4391 }
4392 if chroma_shape[0] != luma_shape[0] / 2 {
4393 return Err(Error::InvalidArgument(format!(
4394 "NV12 chroma height {} != luma height / 2 ({})",
4395 chroma_shape[0],
4396 luma_shape[0] / 2
4397 )));
4398 }
4399 }
4400 PixelFormat::Nv16 => {
4401 if chroma_shape[0] != luma_shape[0] {
4402 return Err(Error::InvalidArgument(format!(
4403 "NV16 chroma height {} != luma height {}",
4404 chroma_shape[0], luma_shape[0]
4405 )));
4406 }
4407 }
4408 // NV24's chroma plane is full-resolution (2×-wide interleaved UV),
4409 // which the equal-width plane check above doesn't model. Multiplane
4410 // NV24 is unused (the JPEG decoder emits a contiguous NV24 buffer),
4411 // so it's not supported here yet.
4412 _ => {
4413 return Err(Error::InvalidArgument(format!(
4414 "from_planes only supports NV12 and NV16 (NV24 multiplane not yet \
4415 supported — use a contiguous NV24 tensor), got {format:?}"
4416 )));
4417 }
4418 }
4419
4420 Ok(Tensor {
4421 storage: luma.storage,
4422 format: Some(format),
4423 chroma: Some(Box::new(chroma)),
4424 row_stride: luma.row_stride,
4425 plane_offset: luma.plane_offset,
4426 quantization: luma.quantization,
4427 // A multiplane tensor spans two DMA-BUFs (luma + chroma); CUDA
4428 // external-memory import is per-fd, so there is no single device
4429 // pointer for the composite. Any CUDA handle the luma plane carried
4430 // is intentionally dropped — consumers needing CUDA access to
4431 // multiplane data must import each plane independently.
4432 cuda: None,
4433 colorimetry: luma.colorimetry,
4434 cpu_access: luma.cpu_access,
4435 compression: luma.compression,
4436 // A composed multiplane tensor is a whole image, not a sub-view.
4437 view_origin: None,
4438 })
4439 }
4440
4441 /// Whether this tensor uses separate plane allocations.
4442 pub fn is_multiplane(&self) -> bool {
4443 self.chroma.is_some()
4444 }
4445
4446 /// Access the chroma plane for multiplane semi-planar images.
4447 pub fn chroma(&self) -> Option<&Tensor<T>> {
4448 self.chroma.as_deref()
4449 }
4450
4451 /// Mutable access to the chroma plane for multiplane semi-planar images.
4452 pub fn chroma_mut(&mut self) -> Option<&mut Tensor<T>> {
4453 self.chroma.as_deref_mut()
4454 }
4455
4456 /// Independent tensor over this plane's allocation, copying geometry
4457 /// metadata. Used to answer `ef_tensor_chroma` from the handle without a
4458 /// client-side shadow field.
4459 pub(crate) fn clone_chroma_plane(&self) -> Result<Self> {
4460 // Every storage accepted by `from_planes` implements `view` as a
4461 // shared-backing lens. Using that common path keeps
4462 // `is_multiplane() => chroma().is_some()` true for SHM, PBO and
4463 // D3D11 too, instead of silently losing chroma when a hand-written
4464 // per-storage clone omits a variant.
4465 self.subview(0, self.shape())
4466 }
4467
4468 /// Row stride in bytes (`None` = tightly packed).
4469 pub fn row_stride(&self) -> Option<usize> {
4470 self.row_stride
4471 }
4472
4473 /// Effective row stride in bytes: the stored stride if set, otherwise the
4474 /// minimum stride computed from the format, width, and element size.
4475 /// Returns `None` only when no format is set and no explicit stride was
4476 /// stored via [`set_row_stride`](Self::set_row_stride).
4477 ///
4478 /// **GREY note:** `effective_row_stride()` for a GREY tensor returns the
4479 /// tight `width` bytes (no padding), which is what `normalize_to_numpy` and
4480 /// the CPU convert path expect. The codec's internal `native_row_stride`
4481 /// (64-byte-aligned) is used only during decoding and is not propagated to
4482 /// the tensor's stored stride, so callers reading via
4483 /// `effective_row_stride()` always see the tight value for GREY.
4484 pub fn effective_row_stride(&self) -> Option<usize> {
4485 if let Some(s) = self.row_stride {
4486 return Some(s);
4487 }
4488 let fmt = self.format?;
4489 let w = self.width()?;
4490 let elem = std::mem::size_of::<T>();
4491 Some(match fmt.layout() {
4492 PixelLayout::Packed => w * fmt.channels() * elem,
4493 PixelLayout::Planar => w * elem,
4494 // Semi-planar: minimum stride must cover the even width so the
4495 // interleaved chroma columns are byte-aligned on odd-width images.
4496 PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4497 })
4498 }
4499
4500 /// Copy the tensor's logical bytes into `dst`, compacting away any
4501 /// recorded row-stride padding.
4502 ///
4503 /// The flatness helper for NPU handoff: consumers that need a FLAT
4504 /// layout (e.g. `[1, C, H, W]` for NNAPI/LiteRT when the runtime cannot
4505 /// take a padded pitch) call this when [`row_stride`](Self::row_stride)
4506 /// is `Some` — on a tight tensor it degenerates to one memcpy, so it is
4507 /// safe to call unconditionally. `dst.len()` must equal the tight byte
4508 /// footprint (`shape` product × element size). Zero-copy consumers
4509 /// should prefer the buffer handle + [`Tensor::effective_row_stride`]
4510 /// (Self::effective_row_stride) and skip this copy entirely.
4511 pub fn copy_to_flat(&self, dst: &mut [u8]) -> Result<()> {
4512 let tight_bytes = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(self.shape())?;
4513 if dst.len() != tight_bytes {
4514 return Err(Error::InvalidArgument(format!(
4515 "copy_to_flat: dst is {} bytes but the tensor's tight \
4516 footprint is {tight_bytes} bytes (shape {:?})",
4517 dst.len(),
4518 self.shape()
4519 )));
4520 }
4521 // Read-only: this helper never writes back into the tensor, so a
4522 // read map suffices — and is required on backends (D3D11) that
4523 // reject a writable map on a tensor allocated CpuAccess::Read.
4524 let map = self.map_read()?;
4525 // SAFETY: T is a plain numeric type (crate-wide bound); viewing the
4526 // mapped elements as bytes is sound.
4527 let src: &[u8] = unsafe {
4528 std::slice::from_raw_parts(
4529 map.as_slice().as_ptr() as *const u8,
4530 std::mem::size_of_val(map.as_slice()),
4531 )
4532 };
4533 let Some(stride) = self.row_stride else {
4534 // Tight layout: the mapped window is exactly the logical bytes.
4535 let got = src.len().min(tight_bytes);
4536 if got < tight_bytes {
4537 return Err(Error::InvalidOperation(format!(
4538 "copy_to_flat: mapped {got} bytes < tight footprint {tight_bytes}"
4539 )));
4540 }
4541 dst.copy_from_slice(&src[..tight_bytes]);
4542 return Ok(());
4543 };
4544 // Strided: row count follows the strided-map convention (planar
4545 // stacks C planes of H rows; packed/semi-planar use shape[0]), and
4546 // the logical row is tight_bytes / rows for every layout.
4547 let rows = match self.format.map(|f| f.layout()) {
4548 Some(PixelLayout::Planar) => {
4549 let s = self.shape();
4550 if s.len() < 2 {
4551 return Err(Error::InvalidOperation(
4552 "copy_to_flat: strided planar tensor requires [C, H, W] shape".into(),
4553 ));
4554 }
4555 s[0].checked_mul(s[1]).ok_or_else(|| {
4556 Error::InvalidOperation(format!(
4557 "copy_to_flat: planar rows {} × {} overflows usize",
4558 s[0], s[1]
4559 ))
4560 })?
4561 }
4562 _ => *self.shape().first().ok_or_else(|| {
4563 Error::InvalidOperation("copy_to_flat: tensor has an empty shape".into())
4564 })?,
4565 };
4566 if rows == 0 || !tight_bytes.is_multiple_of(rows) {
4567 return Err(Error::InvalidOperation(format!(
4568 "copy_to_flat: tight footprint {tight_bytes} does not divide \
4569 into {rows} rows"
4570 )));
4571 }
4572 let row_bytes = tight_bytes / rows;
4573 let need = (rows - 1)
4574 .checked_mul(stride)
4575 .and_then(|b| b.checked_add(row_bytes))
4576 .ok_or_else(|| {
4577 Error::InvalidOperation(format!(
4578 "copy_to_flat: stride {stride} × rows {rows} overflows usize"
4579 ))
4580 })?;
4581 if src.len() < need {
4582 return Err(Error::InvalidOperation(format!(
4583 "copy_to_flat: mapped {} bytes but strided rows need {need}",
4584 src.len()
4585 )));
4586 }
4587 for r in 0..rows {
4588 dst[r * row_bytes..(r + 1) * row_bytes]
4589 .copy_from_slice(&src[r * stride..r * stride + row_bytes]);
4590 }
4591 Ok(())
4592 }
4593
4594 /// Set the row stride in bytes for externally allocated buffers with
4595 /// row padding (e.g. V4L2 or GStreamer allocators).
4596 ///
4597 /// The stride is propagated to the EGL DMA-BUF import attributes so
4598 /// the GPU interprets the padded buffer layout correctly. Must be
4599 /// called after [`set_format`](Self::set_format) and before the tensor
4600 /// is first passed to `ImageProcessor::convert`. The stored stride
4601 /// is cleared automatically if the pixel format is later changed.
4602 ///
4603 /// No stride-vs-buffer-size validation is performed because the
4604 /// backing allocation size is not reliably known: external DMA-BUFs
4605 /// may be over-allocated by the allocator, and internal tensors store
4606 /// a logical (unpadded) shape. An incorrect stride will be caught by
4607 /// the EGL driver at import time.
4608 ///
4609 /// # Arguments
4610 ///
4611 /// * `stride` - Row stride in bytes. Must be >= the minimum stride for
4612 /// the format (width * channels * sizeof(T) for packed,
4613 /// width * sizeof(T) for planar/semi-planar).
4614 ///
4615 /// # Errors
4616 ///
4617 /// * `InvalidArgument` if no pixel format is set on this tensor
4618 /// * `InvalidArgument` if `stride` is less than the minimum for the
4619 /// format and width
4620 pub fn set_row_stride(&mut self, stride: usize) -> Result<()> {
4621 let fmt = self.format.ok_or_else(|| {
4622 Error::InvalidArgument("cannot set row_stride without a pixel format".into())
4623 })?;
4624 let w = self.width().ok_or_else(|| {
4625 Error::InvalidArgument("cannot determine width for row_stride validation".into())
4626 })?;
4627 let elem = std::mem::size_of::<T>();
4628 let min_stride = match fmt.layout() {
4629 PixelLayout::Packed => w * fmt.channels() * elem,
4630 PixelLayout::Planar => w * elem,
4631 // Semi-planar: minimum must cover even width for chroma alignment.
4632 PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4633 };
4634 if stride < min_stride {
4635 return Err(Error::InvalidArgument(format!(
4636 "row_stride {stride} < minimum {min_stride} for {fmt:?} at width {w}"
4637 )));
4638 }
4639 self.row_stride = Some(stride);
4640 Ok(())
4641 }
4642
4643 /// Set the row stride without format validation.
4644 ///
4645 /// Use this for raw sub-tensors (e.g. chroma planes) that don't carry
4646 /// format metadata. The caller is responsible for ensuring the stride
4647 /// is valid.
4648 pub fn set_row_stride_unchecked(&mut self, stride: usize) {
4649 self.row_stride = Some(stride);
4650 }
4651
4652 /// Builder-style variant of [`set_row_stride`](Self::set_row_stride),
4653 /// consuming and returning `self`.
4654 ///
4655 /// # Errors
4656 ///
4657 /// Same conditions as [`set_row_stride`](Self::set_row_stride).
4658 pub fn with_row_stride(mut self, stride: usize) -> Result<Self> {
4659 self.set_row_stride(stride)?;
4660 Ok(self)
4661 }
4662
4663 /// Byte offset within the DMA-BUF where image data starts (`None` = 0).
4664 pub fn plane_offset(&self) -> Option<usize> {
4665 self.plane_offset
4666 }
4667
4668 /// The parent-image snapshot if this tensor is a [`view`](Self::view)/
4669 /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. The GL
4670 /// backend keys its import on the parent geometry and renders this view as a
4671 /// `glViewport`/`glScissor` ROI at `(x, y, width, height)`. See [`ViewOrigin`].
4672 pub fn view_origin(&self) -> Option<ViewOrigin> {
4673 self.view_origin
4674 }
4675
4676 /// Set the byte offset within the DMA-BUF where image data starts.
4677 ///
4678 /// Propagated to `EGL_DMA_BUF_PLANE0_OFFSET_EXT` on GPU import.
4679 /// Unlike [`set_row_stride`](Self::set_row_stride), no format is required
4680 /// since the offset is format-independent.
4681 pub fn set_plane_offset(&mut self, offset: usize) {
4682 self.plane_offset = Some(offset);
4683 // The offset consulted by `map()` lives inside the storage variant.
4684 // Keep it in sync with the wrapper field for every backing that
4685 // honors it; see also the clear sites in `set_format` and `reshape`.
4686 //
4687 // Note that `TensorStorage::Dma` is a cfg-multiplexed *name* rather
4688 // than one type -- `DmaTensor` on Linux, `IoSurfaceTensor` on
4689 // macOS/iOS, `AHardwareBufferTensor` on Android,
4690 // `D3d11TextureTensor` on Windows -- so each platform needs its own
4691 // arm below; a missing one would silently become a fall-through
4692 // rather than a compile error, which is how macOS went without one
4693 // for a while. The match is kept exhaustive (no `_ => {}`)
4694 // specifically so that hazard is a compile error, not a silent gap.
4695 match self.storage {
4696 TensorStorage::Mem(ref mut m) => m.set_offset(offset),
4697 #[cfg(target_os = "linux")]
4698 TensorStorage::Dma(ref mut dma) => dma.mmap_offset = offset,
4699 // The offset `IoSurfaceTensor::scoped_pin`/`map_inner` add to the
4700 // locked base address. Writing it here is what makes a
4701 // *reconstructed* view address its own sub-region instead of the
4702 // parent surface's origin; `IoSurfaceTensor::view` already set it
4703 // on the fresh-view path, which is why only the descriptor round
4704 // trip was ever wrong. Idempotent against `subview`, which sets
4705 // the same absolute value by both routes -- pinned by
4706 // `nested_iosurface_subviews_do_not_compound_their_plane_offset`.
4707 #[cfg(any(target_os = "macos", target_os = "ios"))]
4708 TensorStorage::Dma(ref mut io) => io.view_offset = offset,
4709 // The offset every `D3d11TextureTensor` pin adds to its host or
4710 // staging base, in backing (pitched) space like `row_stride()`.
4711 // `view()` bounds-checks the value it computes; this route does
4712 // not, so the pins refuse an offset past the backing instead
4713 // (`check_view_offset` in `d3d11/texture.rs`). Pinned by the
4714 // `d3d11` plane-offset tests in `crates/tensor/tests`.
4715 #[cfg(target_os = "windows")]
4716 TensorStorage::Dma(ref mut tex) => tex.set_view_offset(offset),
4717 // The offset `PboTensor::scoped_pin` adds to the mapped base.
4718 // Writing it here is what makes a *reconstructed* view address
4719 // its own sub-region instead of the parent buffer's origin;
4720 // `PboTensor::view` already set it on the fresh-view path, which
4721 // is why only the round trip was ever wrong -- the same shape
4722 // IOSurface had. Idempotent against `subview`, which sets the
4723 // same absolute value by both routes -- pinned by
4724 // `nested_pbo_subviews_do_not_compound_their_plane_offset`.
4725 //
4726 // Not `cfg`-gated, and deliberately so: unlike
4727 // `TensorStorage::Dma` -- a cfg-multiplexed *name* whose gated
4728 // arm silently becomes `_ => {}` on the other targets, the
4729 // mechanism behind this whole issue class -- `TensorStorage::Pbo`
4730 // is one type on every platform.
4731 TensorStorage::Pbo(ref mut p) => p.view_offset = offset,
4732 // The offset `ShmTensor::map_inner`/`host_pin` add to the mmap
4733 // base, exactly like `MemTensor::offset`. `cfg(unix)` here
4734 // mirrors `TensorStorage::Shm`'s own gate on the enum
4735 // definition -- it is not a per-OS multiplex the way
4736 // `TensorStorage::Dma` is, so this arm cannot silently become
4737 // `_ => {}` on a target where the `Shm` variant still exists.
4738 #[cfg(unix)]
4739 TensorStorage::Shm(ref mut s) => s.set_offset(offset),
4740 // STILL INCOMPLETE: `AHardwareBufferTensor` (Android) has a
4741 // `view_offset` that its own `map()` adds and its own `view()`
4742 // sets, but nothing writes it here yet, so restoring an offset
4743 // onto an already-built tensor is silently a no-op on this
4744 // backing -- a caller gets a map at the parent's origin. Not
4745 // reachable through `TensorDyn::import_descriptor` today
4746 // (`import_storage`'s DMABUF arm is `cfg(target_os = "linux")`),
4747 // so it is latent rather than silent. See
4748 // `edgefirst-python-common`'s `interop::apply_plane_offset` for
4749 // the full per-backing accounting. PBO and Shm were on this list
4750 // until Stage B of the client-side-state work gave them the arms
4751 // above.
4752 #[cfg(target_os = "android")]
4753 TensorStorage::Dma(_) => {}
4754 }
4755 }
4756
4757 /// Colorimetry metadata (`None` = undefined; never auto-filled).
4758 /// The CPU access declared for this tensor at allocation (see
4759 /// [`CpuAccess`]). Views share their parent's declaration.
4760 pub fn cpu_access(&self) -> CpuAccess {
4761 self.cpu_access
4762 }
4763
4764 /// Set the declared CPU access without re-allocating — crate-private:
4765 /// the declaration must reflect the underlying allocation's real
4766 /// capabilities (constructors and importers set it; arbitrary widening
4767 /// would defeat the contract).
4768 // Only the Android AHardwareBuffer importer derives a declaration from
4769 // an existing allocation's usage bits today.
4770 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
4771 pub(crate) fn set_cpu_access_unchecked(&mut self, access: CpuAccess) {
4772 self.cpu_access = access;
4773 }
4774
4775 /// The vendor tile-compression scheme recorded at allocation, or
4776 /// `None` for a linear layout. `Some` means the pixels live in a
4777 /// proprietary tile order: the row-stride accessors describe no
4778 /// meaningful linear layout and CPU maps are best-effort (see
4779 /// [`Compression`]). Only Android hardware-only allocations that
4780 /// requested compression record a scheme.
4781 pub fn compression(&self) -> Option<CompressionScheme> {
4782 self.compression
4783 }
4784
4785 /// Record the compression scheme — crate-private: recording is an
4786 /// allocation-time fact ([`Tensor::image_desc`] sets it; arbitrary
4787 /// mutation would misdescribe the physical layout).
4788 // Only the Android allocation path records a scheme today.
4789 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
4790 pub(crate) fn set_compression_unchecked(&mut self, scheme: Option<CompressionScheme>) {
4791 self.compression = scheme;
4792 }
4793
4794 pub fn colorimetry(&self) -> Option<crate::Colorimetry> {
4795 self.colorimetry
4796 }
4797
4798 /// Attach/clear colorimetry metadata.
4799 pub fn set_colorimetry(&mut self, c: Option<crate::Colorimetry>) {
4800 self.colorimetry = c;
4801 }
4802
4803 /// Builder-style colorimetry attach.
4804 pub fn with_colorimetry(mut self, c: crate::Colorimetry) -> Self {
4805 self.colorimetry = Some(c);
4806 self
4807 }
4808
4809 /// Create a zero-copy sub-region view of this tensor's backing buffer.
4810 ///
4811 /// The returned tensor shares this tensor's allocation (no copy) and maps
4812 /// the window `[offset_bytes, offset_bytes + shape.product()*size_of::<T>())`
4813 /// measured from this tensor's own logical start. N sub-views into one
4814 /// parent can be written independently, enabling batched assembly into a
4815 /// single buffer. Identical semantics across `Mem` (shared `Arc`) and
4816 /// `Dma` (shared fd) backings.
4817 ///
4818 /// # Disjointness
4819 ///
4820 /// Independent writes are sound *only* when the windows do not overlap. The
4821 /// shared backing uses interior mutability (`UnsafeCell` cells), so two
4822 /// sub-views whose byte ranges intersect alias the same cells: writing one
4823 /// while reading or writing the other is a data race and therefore
4824 /// **undefined behaviour**. The caller is responsible for keeping the
4825 /// windows disjoint; this method does not check for overlap.
4826 ///
4827 /// # Errors
4828 ///
4829 /// - [`Error::InvalidOperation`] if the backing is not `Mem` or `Dma`, or
4830 /// if `offset_bytes` is not a multiple of `align_of::<T>()`.
4831 /// - [`Error::InsufficientCapacity`] / [`Error::InvalidSize`] if the window
4832 /// exceeds the parent allocation.
4833 pub(crate) fn subview(&self, offset_bytes: usize, shape: &[usize]) -> Result<Tensor<T>> {
4834 // Offset is absolute into the backing allocation: a sub-view of a
4835 // sub-view composes by adding this tensor's own offset.
4836 let abs_offset = self
4837 .plane_offset
4838 .unwrap_or(0)
4839 .checked_add(offset_bytes)
4840 .ok_or(Error::InvalidSize(offset_bytes))?;
4841 // Every backend exposes `view(offset, shape)` via `TensorTrait`, sharing
4842 // the resource AND `BufferIdentity` (unlike `from_fd`/`from_surface`,
4843 // which mint a fresh identity). The GL backend keys the import on the
4844 // shared identity so offset-distinct sub-views of one buffer reuse a
4845 // single import and address their window via `glViewport`. `Mem`/`Shm`
4846 // share via the allocation `Arc` / a cloned fd; `Pbo` via the GL-buffer
4847 // `Arc`; Linux DMA-BUF / macOS IOSurface via the shared fd / CFRetain.
4848 // `TensorStorage::view` performs the one remaining per-variant dispatch.
4849 let mut t = Tensor::wrap(self.storage.view(offset_bytes, shape)?);
4850 // Inherit the parent's image metadata so the view is a ready-to-use
4851 // sub-image (e.g. a `convert()` destination). The offset is applied
4852 // LAST because `set_format` deliberately clears it — the offset is a
4853 // structural property of the sub-region, not format-dependent metadata.
4854 if let Some(fmt) = self.format {
4855 t.set_format(fmt)?;
4856 }
4857 if let Some(rs) = self.row_stride {
4858 t.set_row_stride_unchecked(rs);
4859 }
4860 t.quantization = self.quantization.clone();
4861 // A sub-region of an image carries the parent's colorimetry — it is the
4862 // same pixels, same color encoding. Inherit it like the other image
4863 // metadata above so a sub-view is a faithful convert() source/target.
4864 t.set_colorimetry(self.colorimetry);
4865 // The declared CPU access is a property of the underlying
4866 // allocation, so every view shares the parent's declaration.
4867 t.cpu_access = self.cpu_access;
4868 // Likewise the recorded compression scheme: the view shares the
4869 // parent's physical layout.
4870 t.compression = self.compression;
4871 if abs_offset > 0 {
4872 t.set_plane_offset(abs_offset);
4873 }
4874 Ok(t)
4875 }
4876
4877 /// Borrow batch element `n` of a batched tensor as a zero-copy view.
4878 ///
4879 /// A batched tensor prepends `N` as the leading dimension over the
4880 /// per-element image layout (`[N, H, W, C]` packed or `[N, C, H, W]`
4881 /// planar) — `N` is over the whole per-element block regardless of
4882 /// `HWC`/`CHW`. `batch(n)` returns element `n`: the contiguous per-element
4883 /// region at byte offset `n * element_size`, sharing the parent's
4884 /// `BufferIdentity` and inheriting its format / row stride / colorimetry.
4885 /// `batch(0)` on a tensor with `N == 1` is equivalent to the whole tensor.
4886 ///
4887 /// # Errors
4888 ///
4889 /// - [`Error::BatchIndexOutOfBounds`] if `n >= N`.
4890 /// - [`Error::InvalidShape`] if the tensor is not batched (a formatted
4891 /// tensor whose rank lacks the leading `N`, or an empty shape).
4892 pub fn batch(&self, n: usize) -> Result<Tensor<T>> {
4893 let shape = self.shape();
4894 // With a format we know the exact per-element rank, so a missing leading
4895 // `N` is a misuse we reject rather than silently treating a spatial dim
4896 // as the batch. Raw tensors take shape[0] as `N` by contract.
4897 if let Some(fmt) = self.format {
4898 let elem_rank = match fmt.layout() {
4899 PixelLayout::SemiPlanar => 2,
4900 _ => 3,
4901 };
4902 if shape.len() != elem_rank + 1 {
4903 return Err(Error::InvalidShape(format!(
4904 "batch(): tensor is not batched ({fmt:?} expects a leading N over a \
4905 {elem_rank}-D element, got shape {shape:?})"
4906 )));
4907 }
4908 }
4909 let batch = *shape
4910 .first()
4911 .ok_or_else(|| Error::InvalidShape("batch(): empty shape".into()))?;
4912 if n >= batch {
4913 return Err(Error::BatchIndexOutOfBounds { index: n, batch });
4914 }
4915 let elem_shape: Vec<usize> = shape[1..].to_vec();
4916 let elem_count: usize = elem_shape.iter().product();
4917 let elem_bytes = elem_count
4918 .checked_mul(std::mem::size_of::<T>())
4919 .ok_or(Error::InvalidSize(elem_count))?;
4920 let offset = n.checked_mul(elem_bytes).ok_or(Error::InvalidSize(n))?;
4921 // For a packed `[N, H, W, C]` tensor the N tiles stack vertically in the
4922 // shared buffer, so the GL import sees one `(W, N*H)` parent and each
4923 // tile is the row-band at `y = n*H`. Snapshot that parent so the backend
4924 // imports once and renders the tile via `glViewport`. Non-packed
4925 // (planar/semi-planar) batching keeps the per-slot path for now (planar
4926 // NCHW tiling is a separate step); raw tensors have no pixel geometry.
4927 let view_origin = match self.format.map(|f| f.layout()) {
4928 Some(PixelLayout::Packed) => {
4929 let tile_h = elem_shape[0];
4930 let tile_w = elem_shape[1];
4931 // Per-row pitch of the tall `(W, N*H)` parent — padded stride if
4932 // set, else the tight row width. The GL import keys on this.
4933 let bpp = elem_shape[2] * std::mem::size_of::<T>();
4934 let parent_stride = self.effective_row_stride().unwrap_or(tile_w * bpp);
4935 Some(self.compose_view_origin(tile_w, batch * tile_h, parent_stride, 0, n * tile_h))
4936 }
4937 _ => None,
4938 };
4939 let mut t = self.subview(offset, &elem_shape)?;
4940 t.view_origin = view_origin;
4941 Ok(t)
4942 }
4943
4944 /// Borrow a rectangular spatial sub-region of an image tensor as a
4945 /// zero-copy view — the **destination/source crop** primitive.
4946 ///
4947 /// `region` is in pixels of the image's leading frame. The returned view
4948 /// shares the parent's `BufferIdentity` and addresses the sub-rectangle by
4949 /// offset + the **parent's** row pitch (so each row lands at the correct
4950 /// columns). `convert(src, &mut dst.view(rect), …)` renders into that
4951 /// sub-rectangle of `dst`; a letterbox fit then clears the view and renders
4952 /// the aspect-preserved content into its inner region. `view`/`batch`/the
4953 /// whole tensor are the one coherent destination model — there is no
4954 /// separate `dst_rect`.
4955 ///
4956 /// # Known limitation: bottom-right multi-row views
4957 ///
4958 /// A multi-row view is mapped as `parent_pitch × rows` bytes **from its own
4959 /// offset**, so the window it asks for overruns the allocation by
4960 /// `region.x * bpp` whenever the view is both offset horizontally *and*
4961 /// reaches the parent's last row. Such a view constructs fine but fails at
4962 /// [`map`](TensorTrait::map) time with [`Error::InsufficientCapacity`] —
4963 /// loudly, never as an out-of-bounds access. For a tiled destination this is
4964 /// the bottom row of tiles at every column but the first. The single-row
4965 /// case is already special-cased below (it keeps its own tight stride); the
4966 /// general fix is to clamp the last row's exposure to the view's own row
4967 /// bytes, which is not yet implemented.
4968 ///
4969 /// # Errors
4970 ///
4971 /// - [`Error::RegionOutOfBounds`] if `region` exceeds the image bounds.
4972 /// - [`Error::InvalidOperation`] if the tensor is not a packed-format image
4973 /// (planar/semi-planar spatial sub-rects are not a single strided window;
4974 /// use [`batch`](Self::batch) for batched planar tensors).
4975 pub fn view(&self, region: Region) -> Result<Tensor<T>> {
4976 let fmt = self.format.ok_or_else(|| {
4977 Error::InvalidOperation("view() requires a formatted image tensor".into())
4978 })?;
4979 if fmt.layout() != PixelLayout::Packed {
4980 return Err(Error::InvalidOperation(format!(
4981 "view() supports packed formats only (got {fmt:?}); use batch(n) for batched \
4982 planar tensors"
4983 )));
4984 }
4985 let w = self
4986 .width()
4987 .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image width".into()))?;
4988 let h = self
4989 .height()
4990 .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image height".into()))?;
4991 if !region.fits_within(w, h) {
4992 return Err(Error::RegionOutOfBounds {
4993 region,
4994 bounds: (w, h),
4995 });
4996 }
4997 let elem = std::mem::size_of::<T>();
4998 let bpp = fmt.channels() * elem;
4999 let stride = self.effective_row_stride().unwrap_or(w * bpp);
5000 let offset = region
5001 .y
5002 .checked_mul(stride)
5003 .and_then(|yo| yo.checked_add(region.x.checked_mul(bpp)?))
5004 .ok_or(Error::InvalidSize(region.y))?;
5005 let sub_shape = fmt
5006 .allocation_shape(region.width, region.height)
5007 .ok_or_else(|| Error::InvalidShape(format!("view(): invalid shape for {fmt:?}")))?;
5008 let mut t = self.subview(offset, &sub_shape)?;
5009 // A multi-row sub-rect must advance rows by the PARENT pitch so each row
5010 // addresses the correct columns. A single-row view uses its own tight
5011 // stride — the parent pitch would make the strided `map()` (which exposes
5012 // `stride × rows`) expose a trailing row that runs past the buffer tail
5013 // for an offset (x>0 / bottom) view. The GL backend does NOT rely on this
5014 // (single-row-tight) `row_stride`: it reads the parent pitch from
5015 // `view_origin.parent_row_stride` so its import/cache pitch stays
5016 // parent-consistent for views of any height — see `ViewOrigin`.
5017 let view_stride = if region.height > 1 {
5018 stride
5019 } else {
5020 region.width * bpp
5021 };
5022 t.set_row_stride_unchecked(view_stride);
5023 // Snapshot the parent `(w, h, row_stride)` so the GL backend imports the
5024 // parent once (keyed on the parent pitch, not this view's possibly-tight
5025 // single-row stride) and renders this sub-rect as a `glViewport`/
5026 // `glScissor` ROI at `(region.x, region.y)`. Composes when viewing an
5027 // existing view.
5028 t.view_origin = Some(self.compose_view_origin(w, h, stride, region.x, region.y));
5029 Ok(t)
5030 }
5031
5032 /// Build the [`ViewOrigin`] for a new sub-region of `self`. When `self` is a
5033 /// whole tensor the snapshot names `self` as the parent; when `self` is
5034 /// already a view, the snapshot keeps the **root** parent and accumulates the
5035 /// local origin so nested views still resolve to one import.
5036 fn compose_view_origin(
5037 &self,
5038 parent_width: usize,
5039 parent_height: usize,
5040 parent_row_stride: usize,
5041 x: usize,
5042 y: usize,
5043 ) -> ViewOrigin {
5044 match self.view_origin {
5045 Some(root) => ViewOrigin {
5046 parent_width: root.parent_width,
5047 parent_height: root.parent_height,
5048 parent_row_stride: root.parent_row_stride,
5049 x: root.x.saturating_add(x),
5050 y: root.y.saturating_add(y),
5051 },
5052 None => ViewOrigin {
5053 parent_width,
5054 parent_height,
5055 parent_row_stride,
5056 x,
5057 y,
5058 },
5059 }
5060 }
5061
5062 /// The wrapped `PboTensor`, for this module's own PBO accessors.
5063 ///
5064 /// Crate-private: `PboTensor<T>` is a `static`-side value with no
5065 /// `dynamic` counterpart to lend (there the buffer lives inside
5066 /// `libedgefirst_tensor.so`), so a public borrow of it could not have
5067 /// the same signature on both backends. Callers use
5068 /// [`Self::pbo_id`]/[`Self::pbo_is_mapped`], which are the whole surface
5069 /// `edgefirst-image` ever used.
5070 pub(crate) fn as_pbo(&self) -> Option<&PboTensor<T>> {
5071 match &self.storage {
5072 TensorStorage::Pbo(p) => Some(p),
5073 _ => None,
5074 }
5075 }
5076
5077 /// The GL buffer ID backing this tensor (for GL backends / the
5078 /// cross-package protocol's `handle`). `None` when the tensor is not
5079 /// PBO-backed.
5080 pub fn pbo_id(&self) -> Option<u32> {
5081 self.as_pbo().map(PboTensor::buffer_id)
5082 }
5083
5084 /// Whether this PBO currently holds -- or is establishing -- a CPU
5085 /// mapping. `None` when the tensor is not PBO-backed.
5086 ///
5087 /// The question a GL caller is really asking is "is this buffer free
5088 /// for GL operations?", so a map or unmap still in flight answers
5089 /// `Some(true)`: the GL side must not touch the buffer until the
5090 /// transition finishes. See [`PboTensor::is_mapped`].
5091 pub fn pbo_is_mapped(&self) -> Option<bool> {
5092 self.as_pbo().map(PboTensor::is_mapped)
5093 }
5094
5095 /// The C-ABI `PboOpsVtable` address backing this tensor (for the
5096 /// cross-package protocol's `ptr` under `kind::PBO`; see
5097 /// [`crate::protocol::TensorDesc::ptr`]'s doc comment). `None` when the
5098 /// tensor is not PBO-backed.
5099 pub fn pbo_vtable_ptr(&self) -> Option<*const std::ffi::c_void> {
5100 self.as_pbo()
5101 .map(|p| p.pbo_vtable() as *const _ as *const std::ffi::c_void)
5102 }
5103
5104 /// A type-erased keepalive that must stay alive for at least as long as
5105 /// [`Self::pbo_vtable_ptr`]'s address is used -- see
5106 /// [`PboTensor::pbo_keepalive`]'s own doc comment. `None` when the
5107 /// tensor is not PBO-backed.
5108 pub fn pbo_keepalive(&self) -> Option<Arc<dyn Send + Sync>> {
5109 self.as_pbo().map(PboTensor::pbo_keepalive)
5110 }
5111
5112 /// Downcast to DMA tensor reference (for EGL import, G2D).
5113 #[cfg(target_os = "linux")]
5114 pub fn as_dma(&self) -> Option<&DmaTensor<T>> {
5115 match &self.storage {
5116 TensorStorage::Dma(d) => Some(d),
5117 _ => None,
5118 }
5119 }
5120
5121 /// Borrow the DMA-BUF file descriptor backing this tensor.
5122 ///
5123 /// # Returns
5124 ///
5125 /// A borrowed reference to the DMA-BUF file descriptor, tied to `self`'s
5126 /// lifetime.
5127 ///
5128 /// # Errors
5129 ///
5130 /// Returns `Error::NotImplemented` if the tensor is not DMA-backed.
5131 #[cfg(target_os = "linux")]
5132 pub fn dmabuf(&self) -> Result<std::os::fd::BorrowedFd<'_>> {
5133 use std::os::fd::AsFd;
5134 match &self.storage {
5135 TensorStorage::Dma(dma) => Ok(dma.fd.as_fd()),
5136 _ => Err(Error::NotImplemented(format!(
5137 "dmabuf requires DMA-backed tensor, got {:?}",
5138 self.storage.memory()
5139 ))),
5140 }
5141 }
5142
5143 /// Construct a Tensor from a PBO tensor (for GL backends that allocate PBOs).
5144 ///
5145 /// Infallible in this backend (`Ok` always) -- kept as `Result` to match
5146 /// `dynamic`'s own `Tensor::from_pbo` (`dynamic_tensor.rs`), which
5147 /// genuinely can fail: it must allocate a real `ef_tensor_*` handle
5148 /// alongside `pbo` to carry shape/dtype/format metadata (this backend
5149 /// has nowhere else to put it), an allocation this backend never
5150 /// needed since it just wraps `pbo` into `TensorStorage::Pbo` directly.
5151 /// One shared, fallible signature lets `edgefirst-image`'s own call
5152 /// sites (`gl/threaded.rs`) stay backend-agnostic.
5153 pub fn from_pbo(pbo: PboTensor<T>) -> Result<Self> {
5154 Ok(Self {
5155 storage: TensorStorage::Pbo(pbo),
5156 format: None,
5157 chroma: None,
5158 row_stride: None,
5159 plane_offset: None,
5160 quantization: None,
5161 cuda: None,
5162 colorimetry: None,
5163 cpu_access: CpuAccess::ReadWrite,
5164 compression: None,
5165 view_origin: None,
5166 })
5167 }
5168
5169 /// The CUDA registration for this tensor, if any (set at creation on CUDA devices).
5170 pub fn cuda(&self) -> Option<&crate::cuda::CudaHandle> {
5171 self.cuda.as_ref()
5172 }
5173
5174 /// Whether a CUDA registration is attached to this tensor.
5175 pub fn is_cuda_attached(&self) -> bool {
5176 self.cuda.is_some()
5177 }
5178
5179 /// Attach a CUDA handle (called by ImageProcessor::create_image after registering a PBO).
5180 pub fn set_cuda_handle(&mut self, h: crate::cuda::CudaHandle) {
5181 self.cuda = Some(h);
5182 }
5183
5184 /// Fast-fail CUDA map: None (no GL routing) when no handle; else map (PBO routes to the GL worker).
5185 ///
5186 /// Returns a scoped [`CudaMap`] guard holding the raw CUDA device pointer
5187 /// for the duration of the mapping. For GL-buffer-backed tensors the unmap is deferred until the
5188 /// guard drops, freeing the PBO for the next `convert()` call. When no CUDA handle is attached
5189 /// (the common case for plain `Mem`/`DMA` tensors without CUDA registration), returns `None`
5190 /// immediately — no GL routing, no allocation.
5191 ///
5192 /// # Example — zero-copy CUDA input with host fallback
5193 ///
5194 /// ```no_run
5195 /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
5196 /// # fn feed_tensorrt(_dptr: *mut std::ffi::c_void, _bytes: usize) {}
5197 /// # fn demo(t: &Tensor<f32>) {
5198 /// // Try the zero-copy CUDA device pointer first.
5199 /// if let Some(cuda) = t.cuda_map() {
5200 /// feed_tensorrt(cuda.device_ptr(), cuda.len());
5201 /// // `cuda` (a CudaMap guard) unmaps when it goes out of scope, freeing
5202 /// // the GPU buffer for the next convert().
5203 /// } else {
5204 /// // Fall back to the host mapping when no CUDA handle is attached.
5205 /// let _host = t.map().expect("host map fallback must succeed");
5206 /// // `_host` is a TensorMap<f32> that derefs to &[f32].
5207 /// }
5208 /// # }
5209 /// ```
5210 ///
5211 /// # D3D11 texture tensors: the mapping is tight rows
5212 ///
5213 /// On Windows the mapping is a linear copy of the texture laid out at
5214 /// `width * bytes_per_texel`, and `len()` is that times the row count --
5215 /// *not* [`row_stride`](Self::row_stride), which reports the D3D11 staging
5216 /// pitch a CPU map sees and is the larger number on a padded backing. A
5217 /// consumer that strides the device pointer by `row_stride()` reads past
5218 /// the end of the mapping.
5219 ///
5220 /// Semi-planar formats (NV12, NV16, NV24) are the exception: their texture
5221 /// is allocated as wide as its own row pitch, so the tight row *is* the
5222 /// pitch and the mapping equals `row_stride() x combined_height` bytes.
5223 /// Striding the device pointer by `row_stride()` is exactly right there.
5224 pub fn cuda_map(&self) -> Option<crate::cuda::CudaMap<'_>> {
5225 self.cuda.as_ref()?.map()
5226 }
5227
5228 /// Writable counterpart of [`cuda_map`](Self::cuda_map). See
5229 /// [`CudaHandle::map_mut`](crate::cuda::CudaHandle::map_mut) for which
5230 /// backings distinguish the two.
5231 ///
5232 /// The D3D11 arm is the one that does: the guard's release copies the
5233 /// buffer back into the texture. Its layout is [`cuda_map`](Self::cuda_map)'s
5234 /// -- tight rows of `width * bytes_per_texel`, not `row_stride()` -- and
5235 /// writing it at the wrong pitch scrambles the image rather than failing.
5236 pub fn cuda_map_mut(&self) -> Option<crate::cuda::CudaMap<'_>> {
5237 self.cuda.as_ref()?.map_mut()
5238 }
5239
5240 /// Attempt to attach a CUDA `ExternalMemory` handle for DMA-backed tensors.
5241 ///
5242 /// On a CUDA-capable host, imports the DMA-BUF fd via
5243 /// `cudaImportExternalMemory(OpaqueFd)` and maps it to a device pointer.
5244 /// Sets `self.cuda` to a persistent `ExternalMem` handle on success. No-op
5245 /// if CUDA is unavailable, the tensor is not DMA-backed, or a handle is
5246 /// already set. Import failure is silently ignored — the tensor remains
5247 /// usable without a CUDA handle.
5248 ///
5249 /// # RUNTIME-UNVALIDATED
5250 ///
5251 /// No test platform has both `/dev/dma_heap` and a CUDA device. ABI is
5252 /// layout-asserted vs. CUDA 12.6 `driver_types.h`; the mechanism is proven
5253 /// by gpu-probe O5 on Orin. Best-effort: tensor creation never fails here.
5254 #[cfg(target_os = "linux")]
5255 pub fn try_init_dma_cuda(&mut self) {
5256 // Fast-path: already imported, CUDA not available, or not a DMA tensor.
5257 if self.cuda.is_some() || !crate::cuda::is_cuda_available() {
5258 return;
5259 }
5260 let (raw_fd, buf_size) = match &self.storage {
5261 TensorStorage::Dma(dma) => {
5262 use std::os::fd::AsRawFd;
5263 (dma.fd.as_raw_fd(), dma.buf_size)
5264 }
5265 _ => return,
5266 };
5267 if let Some((ext, dptr)) = crate::cuda::import_dma_fd(raw_fd, buf_size) {
5268 self.cuda = Some(crate::cuda::CudaHandle::new_external(ext, dptr, buf_size));
5269 }
5270 }
5271
5272 /// Attempt to attach a CUDA external-memory handle for D3D11 texture
5273 /// tensors — the Windows counterpart of
5274 /// [`try_init_dma_cuda`](Self::try_init_dma_cuda).
5275 ///
5276 /// Imports the texture's shared NT handle via
5277 /// `cudaImportExternalMemory(D3D11Resource)` and the process device's
5278 /// fence as an external semaphore, so [`cuda_map`](Self::cuda_map) hands
5279 /// out a linear device buffer whose refresh is ordered after the D3D11
5280 /// work a producer recorded with [`set_gpu_write`](Self::set_gpu_write).
5281 ///
5282 /// No-op when a handle is already attached or the tensor is not
5283 /// texture-backed. Import failure is silently ignored: the WARP adapter
5284 /// has no CUDA device at all, and a texture whose import fails is still a
5285 /// valid tensor whose `cuda_map()` is `None`.
5286 #[cfg(target_os = "windows")]
5287 pub fn try_init_d3d11_cuda(&mut self) {
5288 if self.cuda.is_some() {
5289 return;
5290 }
5291 let TensorStorage::Dma(tex) = &self.storage else {
5292 return;
5293 };
5294 if let Some(h) = tex.try_init_cuda() {
5295 self.cuda = Some(h);
5296 }
5297 }
5298}
5299
5300/// Windows D3D11 texture accessors. No `'static` bound: none of these needs
5301/// `dtype_of::<T>()`.
5302#[cfg(all(feature = "static", target_os = "windows"))]
5303impl<T> Tensor<T>
5304where
5305 T: Num + Clone + fmt::Debug + Send + Sync,
5306{
5307 /// The borrowed `ID3D11Texture2D*` backing this tensor, valid while the
5308 /// tensor lives. `None` when the tensor is not texture-backed.
5309 pub fn d3d11_texture(&self) -> Option<*mut std::ffi::c_void> {
5310 match &self.storage {
5311 TensorStorage::Dma(t) => Some(t.texture_ptr()),
5312 _ => None,
5313 }
5314 }
5315
5316 /// The texture geometry the HAL chose for this image: DXGI format, texel
5317 /// dimensions and the matching GL internal format. `None` when the tensor
5318 /// is not texture-backed.
5319 pub fn d3d11_layout(&self) -> Option<crate::d3d11_layout::D3d11ImageLayout> {
5320 match &self.storage {
5321 TensorStorage::Dma(t) => Some(t.layout()),
5322 _ => None,
5323 }
5324 }
5325
5326 /// A duplicated NT handle the caller owns: open it on a D3D12 device,
5327 /// another D3D11 device, or CUDA, or duplicate it into another process.
5328 /// Closing it does not affect this tensor.
5329 pub fn d3d11_shared_handle(&self) -> Result<std::os::windows::io::OwnedHandle> {
5330 match &self.storage {
5331 TensorStorage::Dma(t) => t.shared_handle(),
5332 _ => Err(Error::NotImplemented(
5333 "not a D3D11 texture tensor".to_owned(),
5334 )),
5335 }
5336 }
5337
5338 /// The fence handle plus value a GPU consumer waits on before reading this
5339 /// texture, or `None` when no GPU write has been recorded on it.
5340 pub fn gpu_completion(&self) -> Result<Option<crate::d3d11::GpuCompletion>> {
5341 match &self.storage {
5342 TensorStorage::Dma(t) => t.gpu_completion(),
5343 _ => Err(Error::NotImplemented(
5344 "not a D3D11 texture tensor".to_owned(),
5345 )),
5346 }
5347 }
5348
5349 /// Record that GPU work writing this texture completes at `value` of the
5350 /// process device's fence. Monotonic: an older value never displaces a
5351 /// newer one, so recording out of order is safe rather than merely
5352 /// tolerated.
5353 ///
5354 /// Takes `&self`, not `&mut self`: the fence slot is an `AtomicU64` and
5355 /// this is a `fetch_max` on it. A producer records a write on the very
5356 /// tensor its consumers already hold and are reading, so demanding
5357 /// exclusive access here would forbid the one call this exists for.
5358 pub fn set_gpu_write(&self, value: u64) -> Result<()> {
5359 match &self.storage {
5360 TensorStorage::Dma(t) => {
5361 t.set_gpu_write(value);
5362 Ok(())
5363 }
5364 _ => Err(Error::NotImplemented(
5365 "not a D3D11 texture tensor".to_owned(),
5366 )),
5367 }
5368 }
5369
5370 /// The tensor's own NT handle value -- not a duplicate -- for descriptors
5371 /// whose consumer keeps the producing tensor alive.
5372 ///
5373 /// Borrowed rather than duplicated because the C exports (task A6) hand
5374 /// out owned duplicates, which a `Copy` descriptor cannot close; the
5375 /// cross-package protocol's `kind::D3D11_TEXTURE` reads this instead.
5376 pub(crate) fn d3d11_shared_handle_value(&self) -> Option<usize> {
5377 match &self.storage {
5378 TensorStorage::Dma(t) => Some(t.shared_handle_value()),
5379 _ => None,
5380 }
5381 }
5382}
5383
5384/// The recorded GPU write value, on every platform: the descriptor path and
5385/// the Python bindings forward it without a `cfg` of their own, and only
5386/// Windows can record one.
5387#[cfg(feature = "static")]
5388impl<T> Tensor<T>
5389where
5390 T: Num + Clone + fmt::Debug + Send + Sync,
5391{
5392 /// The fence value of the newest GPU write recorded on this tensor, or 0
5393 /// when there is none: the `value` of `gpu_completion` without the
5394 /// duplicated fence handle, for a consumer that already holds the process
5395 /// fence. 0 for a tensor that is not a D3D11 texture, and on every
5396 /// platform other than Windows.
5397 pub fn gpu_write_value(&self) -> u64 {
5398 #[cfg(target_os = "windows")]
5399 {
5400 if let TensorStorage::Dma(t) = &self.storage {
5401 return t.last_gpu_write();
5402 }
5403 }
5404 0
5405 }
5406}
5407
5408/// Records the D3D11 backing's row pitch on `t` when it exceeds the natural
5409/// stride for its format and width.
5410///
5411/// The staging pitch is the row stride CPU consumers see, and for a
5412/// semi-planar tensor it is also the texture's own width, which the GL
5413/// backend's Path B shader takes as its `tex_width`. Three constructors need
5414/// it identically -- `Tensor::image`'s Windows arm and the two wrap
5415/// constructors below -- and a wrapped texture is the case that makes it
5416/// load-bearing: without it such a tensor reports the natural `even(width)`
5417/// stride while its texture is the driver's pitch wide, and the shader samples
5418/// a padded texture at the image width.
5419///
5420/// `t.format` must already be set: the natural stride comes from it.
5421#[cfg(all(feature = "static", target_os = "windows"))]
5422fn record_d3d11_row_stride<T>(t: &mut Tensor<T>, format: PixelFormat, what: &str)
5423where
5424 T: Num + Clone + fmt::Debug + Send + Sync,
5425{
5426 let pitch = match &t.storage {
5427 TensorStorage::Dma(tex) => tex.image_backing_row_stride(),
5428 _ => None,
5429 };
5430 let (Some(pitch), Some(natural)) = (pitch, t.effective_row_stride()) else {
5431 return;
5432 };
5433 if pitch > natural {
5434 log::debug!(
5435 "{what}: the D3D11 backing pitch for the {format:?} texture is {pitch} bytes \
5436 (natural {natural}); recording row stride"
5437 );
5438 t.set_row_stride_unchecked(pitch);
5439 }
5440}
5441
5442/// The two texture-importing constructors. Separate from the accessors above
5443/// because `dtype_of::<T>()` needs `T: 'static`.
5444#[cfg(all(feature = "static", target_os = "windows"))]
5445impl<T> Tensor<T>
5446where
5447 T: Num + Clone + fmt::Debug + Send + Sync + 'static,
5448{
5449 /// Wrap an existing `ID3D11Texture2D` as a tensor. The texture is checked
5450 /// against the layout the HAL would have chosen for `format`/`width`/
5451 /// `height` and must live on the HAL's process device; ownership stays
5452 /// with the caller, since this takes its own reference.
5453 ///
5454 /// The backing's row pitch is recorded as the tensor's
5455 /// [`row_stride`](Self::row_stride) when it exceeds the natural stride, so
5456 /// a wrapped semi-planar texture -- whose width *is* the pitch -- reports
5457 /// the geometry its consumers address it by.
5458 ///
5459 /// # Safety
5460 ///
5461 /// `texture` must be null or a live `ID3D11Texture2D` created on the HAL
5462 /// device ([`crate::d3d11::device()`]).
5463 pub unsafe fn from_d3d11_texture(
5464 texture: *mut std::ffi::c_void,
5465 width: usize,
5466 height: usize,
5467 format: PixelFormat,
5468 access: CpuAccess,
5469 name: Option<&str>,
5470 ) -> Result<Self> {
5471 let dtype = dtype_of::<T>().ok_or_else(|| {
5472 Error::InvalidArgument(format!(
5473 "from_d3d11_texture: element type {} has no DType",
5474 std::any::type_name::<T>()
5475 ))
5476 })?;
5477 let shape = format.allocation_shape(width, height).ok_or_else(|| {
5478 Error::InvalidArgument(format!(
5479 "invalid dimensions {width}x{height} for format {format:?}"
5480 ))
5481 })?;
5482 // SAFETY: the caller guarantees `texture` is null or a live
5483 // `ID3D11Texture2D`; the storage takes its own reference.
5484 let storage = unsafe {
5485 crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_texture(
5486 texture, width, height, format, dtype, &shape, access, name,
5487 )
5488 }?;
5489 let mut t = Self::wrap(TensorStorage::Dma(storage));
5490 t.format = Some(format);
5491 record_d3d11_row_stride(&mut t, format, "from_d3d11_texture");
5492 t.cpu_access = access;
5493 t.try_init_d3d11_cuda();
5494 Ok(t)
5495 }
5496
5497 /// Open a shared texture by its NT handle on the HAL's device. When
5498 /// `completion` is given, its fence value is waited for on the immediate
5499 /// context, so a same-device reader needs no further ordering. Both
5500 /// handles stay owned by the caller: this duplicates what it keeps.
5501 ///
5502 /// Records the backing's row pitch as
5503 /// [`row_stride`](Self::row_stride) on the same rule as
5504 /// [`from_d3d11_texture`](Self::from_d3d11_texture).
5505 ///
5506 /// # Safety
5507 ///
5508 /// `handle` must be an NT shared handle of a D3D11 texture, valid in this
5509 /// process, and `completion`'s handle a shared fence handle.
5510 pub unsafe fn from_d3d11_shared_handle(
5511 handle: std::os::windows::io::RawHandle,
5512 width: usize,
5513 height: usize,
5514 format: PixelFormat,
5515 access: CpuAccess,
5516 completion: Option<(std::os::windows::io::RawHandle, u64)>,
5517 name: Option<&str>,
5518 ) -> Result<Self> {
5519 let dtype = dtype_of::<T>().ok_or_else(|| {
5520 Error::InvalidArgument(format!(
5521 "from_d3d11_shared_handle: element type {} has no DType",
5522 std::any::type_name::<T>()
5523 ))
5524 })?;
5525 let shape = format.allocation_shape(width, height).ok_or_else(|| {
5526 Error::InvalidArgument(format!(
5527 "invalid dimensions {width}x{height} for format {format:?}"
5528 ))
5529 })?;
5530 // SAFETY: the caller guarantees the handles are valid in this process.
5531 let storage = unsafe {
5532 crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_shared_handle(
5533 handle, width, height, format, dtype, &shape, access, completion, name,
5534 )
5535 }?;
5536 let mut t = Self::wrap(TensorStorage::Dma(storage));
5537 t.format = Some(format);
5538 record_d3d11_row_stride(&mut t, format, "from_d3d11_shared_handle");
5539 t.cpu_access = access;
5540 t.try_init_d3d11_cuda();
5541 Ok(t)
5542 }
5543}
5544
5545// Quantization accessors — type-gated to integer element types via the
5546// sealed `IntegerType` trait. Calling `.quantization()` on a `Tensor<f32>`
5547// produces a compile error, not a runtime one.
5548#[cfg(feature = "static")]
5549impl<T> Tensor<T>
5550where
5551 T: IntegerType + Num + Clone + fmt::Debug + Send + Sync,
5552{
5553 /// Quantization metadata for this tensor, if set.
5554 pub fn quantization(&self) -> Option<&Quantization> {
5555 self.quantization.as_ref()
5556 }
5557
5558 /// Attach quantization metadata to this tensor. Validates against the
5559 /// tensor's shape — returns [`Error::QuantizationInvalid`] on any
5560 /// inconsistency (mismatched scale/zp lengths, out-of-range axis, etc.).
5561 pub fn set_quantization(&mut self, q: Quantization) -> Result<()> {
5562 q.validate(self.shape())?;
5563 self.quantization = Some(q);
5564 Ok(())
5565 }
5566
5567 /// Builder-style variant of [`Self::set_quantization`]. Consumes `self`
5568 /// and returns `Result<Self>` — on success yields the tensor with the
5569 /// attached quantization; on validation failure returns
5570 /// [`Error::QuantizationInvalid`] and drops `self` (the tensor is not
5571 /// returned in the error arm).
5572 pub fn with_quantization(mut self, q: Quantization) -> Result<Self> {
5573 self.set_quantization(q)?;
5574 Ok(self)
5575 }
5576
5577 /// Clear any quantization metadata on this tensor.
5578 pub fn clear_quantization(&mut self) {
5579 self.quantization = None;
5580 }
5581}
5582
5583#[cfg(feature = "static")]
5584fn map_strided_host_view<'a, T>(
5585 tensor: &Tensor<T>,
5586 stride: usize,
5587 access: CpuAccess,
5588 non_blocking: bool,
5589) -> Result<crate::view::HostView<'a, T>>
5590where
5591 T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5592{
5593 // Rows sit at `stride`-byte spacing. The row count is the first
5594 // shape dim for packed `[H, W, C]` and semi-planar `[H*k, W]`,
5595 // but planar `[C, H, W]` stacks C planes of H rows — its surface
5596 // row count is `C × H` (`shape[0]` alone would expose a 3-row
5597 // window and truncate the map; first hit by Android planar-F16
5598 // AHardwareBuffers, whose gralloc pads the pitch — macOS/Linux
5599 // planar pitches happen to be naturally aligned so no stride was
5600 // ever recorded there).
5601 let rows = strided_map_rows(tensor)?;
5602 let total_bytes = stride.checked_mul(rows).ok_or_else(|| {
5603 Error::InvalidOperation(format!(
5604 "Tensor::map: row_stride {stride} × rows {rows} overflows usize"
5605 ))
5606 })?;
5607 map_strided_storage(tensor, stride, rows, total_bytes, access, non_blocking)
5608}
5609
5610#[cfg(feature = "static")]
5611fn strided_map_rows<T>(tensor: &Tensor<T>) -> Result<usize>
5612where
5613 T: Num + Clone + fmt::Debug + Send + Sync,
5614{
5615 match tensor.format.map(|f| f.layout()) {
5616 Some(PixelLayout::Planar) => {
5617 let s = tensor.shape();
5618 if s.len() < 2 {
5619 return Err(Error::InvalidOperation(
5620 "Tensor::map: strided planar mapping requires [C, H, W] shape".into(),
5621 ));
5622 }
5623 s[0].checked_mul(s[1]).ok_or_else(|| {
5624 Error::InvalidOperation(format!(
5625 "Tensor::map: planar rows {} × {} overflows usize",
5626 s[0], s[1]
5627 ))
5628 })
5629 }
5630 _ => tensor.shape().first().copied().ok_or_else(|| {
5631 Error::InvalidOperation(
5632 "Tensor::map: strided mapping requires a non-empty shape".into(),
5633 )
5634 }),
5635 }
5636}
5637
5638#[cfg(feature = "static")]
5639fn map_strided_storage<'a, T>(
5640 tensor: &Tensor<T>,
5641 _stride: usize,
5642 _rows: usize,
5643 total_bytes: usize,
5644 access: CpuAccess,
5645 _non_blocking: bool,
5646) -> Result<crate::view::HostView<'a, T>>
5647where
5648 T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5649{
5650 match &tensor.storage {
5651 #[cfg(target_os = "linux")]
5652 TensorStorage::Dma(dma) => {
5653 // `set_row_stride()` only validates `stride >= min_stride`,
5654 // not that `stride × rows` fits the DMA-BUF, so re-check
5655 // here — mapping past `buf_size` would SIGBUS on access.
5656 // `buf_size` is real kernel truth either way, not the
5657 // importer's say-so: for a self-allocated tensor it is
5658 // the allocation size; for an imported one it is
5659 // `fstat`-derived when the kernel reports a usable size
5660 // (`DmaTensor::from_fd`), or the declared logical size in
5661 // the rare fallback -- either way an out-of-range mmap
5662 // against the real dma-buf is still rejected by the
5663 // kernel, so this bound stays sound regardless.
5664 let available_bytes = dma.buf_size.saturating_sub(dma.mmap_offset);
5665 if total_bytes > available_bytes {
5666 return Err(Error::InvalidOperation(format!(
5667 "Tensor::map: strided mapping needs {total_bytes} bytes \
5668 but DMA buffer only has {available_bytes} available \
5669 (buf_size={}, mmap_offset={}, stride={_stride}, rows={_rows}); \
5670 the row_stride was likely set larger than the original allocation",
5671 dma.buf_size, dma.mmap_offset
5672 )));
5673 }
5674 dma.map_with_byte_size(total_bytes, access)
5675 }
5676 TensorStorage::Mem(mem) => {
5677 let capacity = tensor.storage.capacity_bytes();
5678 if total_bytes > capacity {
5679 return Err(Error::InsufficientCapacity {
5680 needed: total_bytes,
5681 capacity,
5682 });
5683 }
5684 mem.map_with_byte_size(total_bytes, access)
5685 }
5686 #[cfg(unix)]
5687 TensorStorage::Shm(shm) => {
5688 let capacity = tensor.storage.capacity_bytes();
5689 if total_bytes > capacity {
5690 return Err(Error::InsufficientCapacity {
5691 needed: total_bytes,
5692 capacity,
5693 });
5694 }
5695 shm.map_with_byte_size(total_bytes, access)
5696 }
5697 // macOS/iOS: `TensorStorage::Dma` is the IOSurface. The lock yields
5698 // the full surface base address, and the row pitch
5699 // (`IOSurfaceGetBytesPerRow`) is known from the API for both
5700 // self-allocated and imported surfaces — unlike a foreign
5701 // DMA-BUF — so a strided CPU view is sound and zero-copy.
5702 #[cfg(any(target_os = "macos", target_os = "ios"))]
5703 TensorStorage::Dma(io) => {
5704 // A sub-view's window is `buf_size − view_offset`; the strided
5705 // span must fit the window, not the whole surface.
5706 let available = io.buf_size.saturating_sub(io.view_offset);
5707 if total_bytes > available {
5708 return Err(Error::InsufficientCapacity {
5709 needed: total_bytes,
5710 capacity: available,
5711 });
5712 }
5713 io.map_with_byte_size(total_bytes, access)
5714 }
5715 // Android: `TensorStorage::Dma` is the AHardwareBuffer. The lock
5716 // yields the full buffer base address, and the row pitch is
5717 // known from the allocator-filled descriptor — so a strided CPU
5718 // view is sound and zero-copy, same as IOSurface.
5719 #[cfg(target_os = "android")]
5720 TensorStorage::Dma(ahb) => {
5721 // A sub-view's window is `buf_size − view_offset`; the strided
5722 // span must fit the window, not the whole buffer.
5723 let available = ahb.buf_size.saturating_sub(ahb.view_offset);
5724 if total_bytes > available {
5725 return Err(Error::InsufficientCapacity {
5726 needed: total_bytes,
5727 capacity: available,
5728 });
5729 }
5730 ahb.map_with_byte_size(total_bytes, access)
5731 }
5732 // Windows: `TensorStorage::Dma` is the D3D11 texture, whose CPU side
5733 // is the staging copy at the driver's pitch -- the very pitch
5734 // `Tensor::image` recorded as this tensor's row stride. Its
5735 // `map_with_byte_size` bounds-checks the span against this tensor's
5736 // own window, as the IOSurface arm above does. This is also the only
5737 // backing with a real non-blocking map, so it is the only arm that
5738 // reads `_non_blocking`.
5739 #[cfg(target_os = "windows")]
5740 TensorStorage::Dma(tex) => {
5741 if _non_blocking {
5742 tex.try_map_with_byte_size(total_bytes, access)
5743 } else {
5744 tex.map_with_byte_size(total_bytes, access)
5745 }
5746 }
5747 TensorStorage::Pbo(pbo) => {
5748 // PBO: the GPU-side allocation may have a padded row stride
5749 // (e.g. 64-byte aligned). Expose the full padded buffer so a
5750 // CPU producer (JPEG decoder) and a strided convert source
5751 // can iterate rows via `effective_row_stride()` without
5752 // running past the slice — the logical `pbo.map()` view would
5753 // stop after `shape.product()` and lose bytes past row 0.
5754 // A sub-view's window is `capacity − view_offset`.
5755 let available = pbo.capacity_bytes().saturating_sub(pbo.view_offset);
5756 if total_bytes > available {
5757 return Err(Error::InsufficientCapacity {
5758 needed: total_bytes,
5759 capacity: available,
5760 });
5761 }
5762 pbo.map_with_byte_size(total_bytes, access)
5763 }
5764 // Every `TensorStorage` variant this build can construct is
5765 // matched explicitly above (Mem/Shm/Dma/Pbo, each cfg-gated
5766 // to the platforms it exists on) -- this catch-all exists
5767 // only so the match stays exhaustive across every
5768 // OS/cfg combination without a matching arm on every one of
5769 // them, not because any variant is meant to fall through to
5770 // it. `#[allow(unreachable_patterns)]` because on any given
5771 // platform it genuinely is unreachable.
5772 #[allow(unreachable_patterns)]
5773 _ => Err(Error::InvalidOperation(
5774 "CPU mapping of strided tensors is supported only for HAL-allocated \
5775 Mem/Shm (any platform), DMA (Linux, self-allocated or imported), \
5776 IOSurface (macOS), AHardwareBuffer (Android), D3D11 textures \
5777 (Windows), and PBO"
5778 .into(),
5779 )),
5780 }
5781}
5782
5783#[cfg(feature = "static")]
5784impl<T> Tensor<T>
5785where
5786 T: Num + Clone + fmt::Debug + Send + Sync,
5787{
5788 /// The shared body of [`TensorTrait::map_with`] and
5789 /// [`TensorTrait::try_map_with`]: the direction check, the
5790 /// declared-vs-requested telemetry, the strided and plane-offset routing,
5791 /// and finally the backing's own map. `non_blocking` chooses which of the
5792 /// backing's two maps that last step takes; everything before it is the
5793 /// same either way.
5794 fn map_impl<'a>(
5795 &self,
5796 access: CpuAccess,
5797 non_blocking: bool,
5798 ) -> Result<crate::view::HostView<'a, T>>
5799 where
5800 T: 'a,
5801 {
5802 let _span = tracing::trace_span!(
5803 "tensor.map",
5804 memory = ?self.storage.memory(),
5805 ?access,
5806 non_blocking,
5807 )
5808 .entered();
5809 if access == CpuAccess::None {
5810 return Err(Error::InvalidArgument(
5811 "map_with(CpuAccess::None) is not a mappable direction — use \
5812 map_read()/map_write()/map_mut()"
5813 .into(),
5814 ));
5815 }
5816 // Declared-vs-requested telemetry (all platforms): mapping beyond
5817 // the allocation-time declaration is best-effort — tolerated where
5818 // the backing is CPU-mappable regardless (Mem/Shm/dma-buf/
5819 // IOSurface), refused by the Android backend for CpuAccess::None
5820 // buffers — but always loud and counted, never silent.
5821 if !self.cpu_access.covers(access) {
5822 note_unplanned_cpu_access(
5823 self.buffer_identity().id(),
5824 &format!("{:?}", self.storage.memory()),
5825 "map access exceeds the declared CpuAccess",
5826 );
5827 }
5828 // CPU mapping of a strided tensor exposes the full padded buffer
5829 // (`row_stride × rows`) so callers can iterate rows via
5830 // `effective_row_stride()` without running past the slice. This is sound
5831 // only when the HAL owns and can size-check the allocation:
5832 //
5833 // * Self-allocated Mem / Shm tensors (any platform) — the backing
5834 // `Vec` / shm segment is sized by `capacity_bytes()`, checked here.
5835 // * Self-allocated DMA tensors (Linux) — pitch padding from
5836 // `image_with_stride()`; checked against the DMA-BUF `buf_size`.
5837 //
5838 // * Self-allocated PBO tensors (any platform with GL) — the GL buffer
5839 // is sized by `capacity_bytes()` and may carry 64-byte row padding;
5840 // the JPEG decoder mmaps it and convert() reads it, both iterating
5841 // by `row_stride`. Checked against the PBO capacity below.
5842 //
5843 // A strided **imported** DMA-BUF is accepted here too (Task 10b): the
5844 // `stride × rows <= buf_size` check just below is exactly what a
5845 // self-allocated Dma tensor relies on for its own soundness, and
5846 // `buf_size` for an imported fd is `fstat`-derived when the kernel
5847 // reports a usable size (`stat.st_size > 0` and at least the
5848 // declared shape's logical size; `DmaTensor::from_fd`) -- kernel
5849 // truth, not a caller's claim, in that case. The rare fallback (a
5850 // kernel that reports `st_size <= 0` for a DMA-BUF fd) uses the
5851 // declared logical size instead; that is not a hole here either, since
5852 // an out-of-range mmap against the real dma-buf is still rejected by
5853 // the kernel regardless of what this crate believed `buf_size` was.
5854 // So the check is equally load-bearing regardless of who allocated
5855 // the buffer. What
5856 // changed is only *which* callers can now reach this path: the
5857 // cross-package capsule protocol reconstructs an imported alias of a
5858 // HAL-owned Dma tensor and then calls `configure_image`, which
5859 // computes its stride with the exact same alignment rule a
5860 // self-allocated `Tensor::image()` used — so the value being
5861 // bounds-checked here is HAL-computed and format/width-derived, not
5862 // read from an external driver. Rejecting it unconditionally used to
5863 // make every foreign-imported GPU-backed destination fail any CPU
5864 // write path (the JPEG decoder's MCU writer among them) even when the
5865 // write was provably in-bounds. `set_row_stride()` was already
5866 // "trust the caller, bounds-check the map" for every *other* backing
5867 // (Mem/Shm/self-allocated Dma/PBO) — singling out imported Dma out
5868 // for a stricter standard was the inconsistency, not the trust model
5869 // itself. IOSurface keeps its own always-allowed strided path
5870 // unconditionally (see its arm below): a live `IOSurfaceRef` reports
5871 // its real `bytesPerRow` from the OS regardless of who allocated it,
5872 // so there was never anything to distrust there.
5873 if let Some(stride) = self.row_stride {
5874 return map_strided_host_view(self, stride, access, non_blocking);
5875 }
5876 // Offset tensors are supported for storages that apply the offset
5877 // inside their own `map()`: DMA (`DmaMap`/IOSurface adjust the mapped
5878 // base), Mem (`MemMap` adjusts the slice base), Shm (`ShmMap` adjusts
5879 // the slice base), and PBO (the staged copy starts at the offset). Every
5880 // self-allocated backing now carries a sub-region concept via `view`, so
5881 // a non-zero offset is honoured rather than rejected.
5882 if self.plane_offset.is_some_and(|o| o > 0) {
5883 let supported = matches!(self.storage, TensorStorage::Mem(_) | TensorStorage::Pbo(_));
5884 // macOS `Dma` is the IOSurface; Linux `Dma` is the DMA-BUF; Android
5885 // `Dma` is the AHardwareBuffer, Windows `Dma` is the D3D11 texture
5886 // (whose pin and map both add the storage's own `view_offset`, the
5887 // same way IOSurface does) — all apply the offset in their map.
5888 // (`Dma` is the same variant name on each, hence one `cfg(any(...))`
5889 // arm rather than four.)
5890 #[cfg(any(
5891 target_os = "linux",
5892 target_os = "macos",
5893 target_os = "ios",
5894 target_os = "android",
5895 target_os = "windows"
5896 ))]
5897 let supported = supported || matches!(self.storage, TensorStorage::Dma(_));
5898 #[cfg(unix)]
5899 let supported = supported || matches!(self.storage, TensorStorage::Shm(_));
5900 if !supported {
5901 return Err(Error::InvalidOperation(
5902 "plane offset only supported for DMA, Mem, Shm, and PBO tensors".into(),
5903 ));
5904 }
5905 }
5906 if non_blocking {
5907 self.storage.try_map_with(access)
5908 } else {
5909 self.storage.map_with(access)
5910 }
5911 }
5912}
5913
5914#[cfg(feature = "static")]
5915impl<T> TensorTrait<T> for Tensor<T>
5916where
5917 T: Num + Clone + fmt::Debug + Send + Sync,
5918{
5919 fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
5920 where
5921 Self: Sized,
5922 {
5923 Self::new(shape, None, name)
5924 }
5925
5926 #[cfg(unix)]
5927 fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
5928 where
5929 Self: Sized,
5930 {
5931 #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
5932 let mut t = Self::wrap(TensorStorage::from_fd(fd, shape, name)?);
5933 // Best-effort CUDA external memory import for DMA-backed tensors.
5934 // RUNTIME-UNVALIDATED: see try_init_dma_cuda().
5935 #[cfg(target_os = "linux")]
5936 t.try_init_dma_cuda();
5937 Ok(t)
5938 }
5939
5940 #[cfg(unix)]
5941 fn clone_fd(&self) -> Result<std::os::fd::OwnedFd> {
5942 self.storage.clone_fd()
5943 }
5944
5945 fn memory(&self) -> TensorMemory {
5946 self.storage.memory()
5947 }
5948
5949 fn name(&self) -> String {
5950 self.storage.name()
5951 }
5952
5953 fn shape(&self) -> &[usize] {
5954 self.storage.shape()
5955 }
5956
5957 /// Bytes available in the underlying allocation, from this tensor's
5958 /// window start -- may exceed [`TensorTrait::size`] when the backing
5959 /// carries spare room beyond the current logical shape (a pitch-aligned
5960 /// image, or a pool tensor sized for a larger image than it holds
5961 /// today). See [`crate::protocol::TensorDesc::capacity`], the
5962 /// cross-package descriptor field this backs.
5963 fn capacity_bytes(&self) -> usize {
5964 self.storage.capacity_bytes()
5965 }
5966
5967 fn reshape(&mut self, shape: &[usize]) -> Result<()> {
5968 if self.chroma.is_some() {
5969 return Err(Error::InvalidOperation(
5970 "cannot reshape a multiplane tensor — decompose planes first".into(),
5971 ));
5972 }
5973 self.storage.reshape(shape)?;
5974 self.format = None;
5975 self.row_stride = None;
5976 self.plane_offset = None;
5977 match self.storage {
5978 TensorStorage::Mem(ref mut m) => m.set_offset(0),
5979 #[cfg(target_os = "linux")]
5980 TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
5981 // No-op: `IoSurfaceTensor::reshape` (iosurface.rs) zeroes its
5982 // own `view_offset` before this match runs.
5983 #[cfg(any(target_os = "macos", target_os = "ios"))]
5984 TensorStorage::Dma(_) => {}
5985 // `D3d11TextureTensor::reshape` does not self-zero, so the clear
5986 // that pairs with `set_plane_offset`'s D3D11 arm lives here.
5987 #[cfg(target_os = "windows")]
5988 TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
5989 // No-op: `AHardwareBufferTensor::reshape` (ahardwarebuffer.rs)
5990 // zeroes its own `view_offset` before this match runs, the same
5991 // as `IoSurfaceTensor`. Unlike `set_plane_offset`'s Android arm,
5992 // this one is a genuine self-zeroing no-op, not a still-
5993 // incomplete gap: `view_offset` never survives a reshape here
5994 // regardless of whether `set_plane_offset` can restore it.
5995 #[cfg(target_os = "android")]
5996 TensorStorage::Dma(_) => {}
5997 // No-op: `PboTensor::reshape` (pbo.rs) zeroes its own
5998 // `view_offset` before this match runs, the same as
5999 // `IoSurfaceTensor`/`AHardwareBufferTensor` above -- which is
6000 // why, unlike `set_format`'s clear block, this match never
6001 // needed a `Pbo` arm that writes anything.
6002 TensorStorage::Pbo(_) => {}
6003 // Unlike `PboTensor::reshape`, `ShmTensor::reshape` (shm.rs)
6004 // only revalidates the element count and swaps `self.shape` --
6005 // it never touches `self.offset` -- so this clear site needs
6006 // the arm `set_format`'s does. `cfg(unix)` mirrors the `Shm`
6007 // variant's own gate, not a per-OS multiplex.
6008 #[cfg(unix)]
6009 TensorStorage::Shm(ref mut s) => s.set_offset(0),
6010 }
6011 Ok(())
6012 }
6013
6014 /// Set the logical shape to any shape whose bytes fit the allocation,
6015 /// without [`reshape`](TensorTrait::reshape)'s equal-count constraint.
6016 ///
6017 /// Overridden rather than inherited, which it was not for most of this
6018 /// crate's life. The trait's default body is `self.reshape(shape)`, so
6019 /// a caller reaching this through `Tensor<T>` silently got the strict
6020 /// rule under a name that promises the opposite -- on both backends,
6021 /// which is why G13 cannot see it.
6022 ///
6023 /// The capacity-aware implementations were never dead: every storage
6024 /// type has one, and [`Tensor::configure_image`] reaches them by
6025 /// calling `self.storage.set_logical_shape(..)` directly. That is the
6026 /// pool-reuse path -- a decode into an oversized reusable buffer
6027 /// reconfiguring it to a smaller image without reallocating -- and it
6028 /// worked precisely because it bypassed this method. This forwards the
6029 /// same way, so the two agree.
6030 ///
6031 /// A narrower image inside a pitched backing (an IOSurface, an
6032 /// AHardwareBuffer, a D3D11 texture) still advances rows by the backing's
6033 /// pitch, so that pitch is recorded as the row stride whenever it exceeds
6034 /// the new shape's natural stride -- the same adoption `configure_image`
6035 /// makes. Without it a texture narrowed from 64 to 16 texels wide reports
6036 /// a 64-byte stride over rows that are 256 bytes apart. A single-row
6037 /// shape records its own tight stride instead, by [`view`](Self::view)'s
6038 /// rule: a strided `map()` spans `stride * rows`, and one pitched row
6039 /// runs past the backing when the window sits in the last row.
6040 fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
6041 self.storage.set_logical_shape(shape)?;
6042 // Only a backing with a pitch of its own, and only while the shape
6043 // still has the rank the format's row is measured on: a caller may
6044 // flatten an image to one dimension and leave the format behind.
6045 let Some(pitch) = self.storage.backing_row_stride() else {
6046 return Ok(());
6047 };
6048 let Some((rank, rows)) = self.format.map(|f| match f.layout() {
6049 PixelLayout::Packed => (3, shape.first().copied()),
6050 PixelLayout::Planar => (3, shape.get(1).copied()),
6051 PixelLayout::SemiPlanar => (2, shape.first().copied()),
6052 }) else {
6053 return Ok(());
6054 };
6055 if rank != shape.len() {
6056 return Ok(());
6057 }
6058 // The natural stride of the new shape, not of the stride recorded for
6059 // the old one.
6060 let prior = self.row_stride.take();
6061 let Some(natural) = self.effective_row_stride() else {
6062 self.row_stride = prior;
6063 return Ok(());
6064 };
6065 if rows == Some(1) {
6066 self.set_row_stride_unchecked(natural);
6067 } else if pitch > natural {
6068 self.set_row_stride_unchecked(pitch);
6069 } else {
6070 self.row_stride = prior;
6071 }
6072 Ok(())
6073 }
6074
6075 fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6076 where
6077 T: 'a,
6078 {
6079 self.map_impl(access, false)
6080 }
6081
6082 fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6083 where
6084 T: 'a,
6085 {
6086 self.map_impl(access, true)
6087 }
6088
6089 fn buffer_identity(&self) -> &BufferIdentity {
6090 self.storage.buffer_identity()
6091 }
6092}
6093
6094// ============================================================================
6095// Platform availability helpers
6096// ============================================================================
6097
6098/// Cached result of the Linux DMA-BUF availability probe.
6099#[cfg(target_os = "linux")]
6100static DMA_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6101/// Cached result of the macOS/iOS IOSurface availability probe.
6102#[cfg(any(target_os = "macos", target_os = "ios"))]
6103static IOSURFACE_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6104
6105/// Check if Linux DMA-BUF allocation is available on this system.
6106///
6107/// Linux-specific availability check (typically requires `/dev/dma_heap`
6108/// access — running as root or membership in a video/render group). For
6109/// portable code that wants "any zero-copy GPU buffer", use
6110/// [`is_gpu_buffer_available`] which also covers IOSurface on macOS.
6111///
6112/// This function caches its result after the first call.
6113#[cfg(target_os = "linux")]
6114pub fn is_dma_available() -> bool {
6115 *DMA_AVAILABLE
6116 .get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok())
6117}
6118
6119/// Always returns `false` on non-Linux platforms.
6120#[cfg(not(target_os = "linux"))]
6121pub fn is_dma_available() -> bool {
6122 false
6123}
6124
6125/// Check if macOS/iOS IOSurface allocation is available on this system.
6126///
6127/// IOSurface is part of the macOS/iOS OS and is essentially always present;
6128/// this probe catches degraded scenarios such as memory pressure or
6129/// sandboxed contexts where `IOSurfaceCreate` fails. The result is
6130/// cached after the first call.
6131///
6132/// Always returns `false` on non-Apple platforms.
6133#[cfg(any(target_os = "macos", target_os = "ios"))]
6134pub fn is_iosurface_available() -> bool {
6135 *IOSURFACE_AVAILABLE.get_or_init(|| {
6136 // Probe via the same Dma path — on macOS/iOS this routes through
6137 // IoSurfaceTensor::new.
6138 Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6139 })
6140}
6141
6142#[cfg(not(any(target_os = "macos", target_os = "ios")))]
6143pub fn is_iosurface_available() -> bool {
6144 false
6145}
6146
6147/// Cached result of the Android AHardwareBuffer availability probe.
6148#[cfg(target_os = "android")]
6149static AHARDWAREBUFFER_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6150
6151/// Check if Android AHardwareBuffer allocation is available on this system.
6152///
6153/// AHardwareBuffer is part of the Android OS (public NDK ABI since API
6154/// 26) and is essentially always present; this probe catches degraded
6155/// scenarios such as memory pressure or gralloc failures. The result is
6156/// cached after the first call.
6157#[cfg(target_os = "android")]
6158pub fn is_ahardwarebuffer_available() -> bool {
6159 *AHARDWAREBUFFER_AVAILABLE.get_or_init(|| {
6160 // Probe via the same Dma path — on Android this routes through
6161 // AHardwareBufferTensor::new.
6162 Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6163 })
6164}
6165
6166/// Always returns `false` on non-Android platforms.
6167#[cfg(not(target_os = "android"))]
6168pub fn is_ahardwarebuffer_available() -> bool {
6169 false
6170}
6171
6172/// Portable probe for the platform's native zero-copy GPU buffer allocator
6173/// (DMA-BUF on Linux, IOSurface on macOS/iOS, AHardwareBuffer on Android,
6174/// D3D11 textures on Windows). Returns `false` on platforms with no
6175/// equivalent. Use this when writing cross-platform code that cares whether
6176/// the `Dma` tensor variant will work, not which underlying mechanism is used.
6177///
6178/// On Windows the `Dma` variant is image-formatted: `true` here means
6179/// [`Tensor::image`] can serve `TensorMemory::DmaBuf`, not [`Tensor::new`].
6180///
6181/// # Ordering on Windows
6182///
6183/// This probe **creates the process D3D11 device**, because the only honest
6184/// answer to "can this host allocate one" is to try. A host that must supply
6185/// its own device calls `d3d11::use_external_device` **before** this probe or
6186/// any allocation: the device is cached on first creation and installing one
6187/// afterwards is refused.
6188pub fn is_gpu_buffer_available() -> bool {
6189 #[cfg(target_os = "linux")]
6190 {
6191 is_dma_available()
6192 }
6193 #[cfg(any(target_os = "macos", target_os = "ios"))]
6194 {
6195 is_iosurface_available()
6196 }
6197 #[cfg(target_os = "android")]
6198 {
6199 is_ahardwarebuffer_available()
6200 }
6201 // No cached probe: `d3d11::device()` is itself a `OnceLock` over the
6202 // process device, so asking twice costs one atomic load.
6203 #[cfg(target_os = "windows")]
6204 {
6205 crate::d3d11::device().is_ok()
6206 }
6207 #[cfg(not(any(
6208 target_os = "linux",
6209 target_os = "macos",
6210 target_os = "ios",
6211 target_os = "android",
6212 target_os = "windows"
6213 )))]
6214 {
6215 false
6216 }
6217}
6218
6219/// Check if POSIX shared memory allocation is available on this system.
6220///
6221/// Returns `true` on Unix systems (Linux, macOS, BSD) where POSIX shared memory
6222/// is supported. Always returns `false` on non-Unix platforms (Windows).
6223///
6224/// This function caches its result after the first call for efficiency.
6225#[cfg(unix)]
6226static SHM_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6227
6228/// Check if POSIX shared memory allocation is available on this system.
6229#[cfg(unix)]
6230pub fn is_shm_available() -> bool {
6231 *SHM_AVAILABLE.get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::Shm), None).is_ok())
6232}
6233
6234/// Check if POSIX shared memory allocation is available on this system.
6235///
6236/// Always returns `false` on non-Unix platforms since POSIX SHM is Unix-specific.
6237#[cfg(not(unix))]
6238pub fn is_shm_available() -> bool {
6239 false
6240}
6241
6242#[cfg(test)]
6243mod dtype_tests {
6244 use super::*;
6245
6246 #[test]
6247 fn dtype_size() {
6248 assert_eq!(DType::U8.size(), 1);
6249 assert_eq!(DType::I8.size(), 1);
6250 assert_eq!(DType::U16.size(), 2);
6251 assert_eq!(DType::I16.size(), 2);
6252 assert_eq!(DType::U32.size(), 4);
6253 assert_eq!(DType::I32.size(), 4);
6254 assert_eq!(DType::U64.size(), 8);
6255 assert_eq!(DType::I64.size(), 8);
6256 assert_eq!(DType::F16.size(), 2);
6257 assert_eq!(DType::F32.size(), 4);
6258 assert_eq!(DType::F64.size(), 8);
6259 }
6260
6261 #[test]
6262 fn dtype_name() {
6263 assert_eq!(DType::U8.name(), "u8");
6264 assert_eq!(DType::F16.name(), "f16");
6265 assert_eq!(DType::F32.name(), "f32");
6266 }
6267
6268 #[test]
6269 fn dtype_serde_roundtrip() {
6270 use serde_json;
6271 let dt = DType::F16;
6272 let json = serde_json::to_string(&dt).unwrap();
6273 let back: DType = serde_json::from_str(&json).unwrap();
6274 assert_eq!(dt, back);
6275 }
6276}
6277
6278#[cfg(test)]
6279mod image_tests {
6280 use super::*;
6281
6282 #[test]
6283 fn image_dims_from_shape_inverts_allocation_shape() {
6284 for (format, w, h) in [
6285 (PixelFormat::Rgba, 640usize, 480usize),
6286 (PixelFormat::Rgb, 641, 3),
6287 (PixelFormat::Grey, 32, 32),
6288 (PixelFormat::PlanarRgb, 640, 480),
6289 (PixelFormat::PlanarRgba, 8, 5),
6290 (PixelFormat::Nv12, 640, 481),
6291 (PixelFormat::Nv16, 33, 17),
6292 (PixelFormat::Nv24, 16, 9),
6293 ] {
6294 let shape = format.allocation_shape(w, h).unwrap();
6295 assert_eq!(
6296 image_dims_from_shape(format, &shape).unwrap(),
6297 (w, h),
6298 "{format:?} {w}x{h} via {shape:?}"
6299 );
6300 }
6301 }
6302
6303 #[test]
6304 fn image_dims_from_shape_rejects_the_wrong_rank() {
6305 // Packed and planar want rank 3, semi-planar rank 2.
6306 for (format, shape) in [
6307 (PixelFormat::Rgba, vec![480usize, 640usize]),
6308 (PixelFormat::PlanarRgb, vec![3, 480]),
6309 (PixelFormat::Nv12, vec![720, 640, 1]),
6310 ] {
6311 let err = image_dims_from_shape(format, &shape).unwrap_err();
6312 assert!(
6313 matches!(err, Error::InvalidShape(ref m) if m.contains("rank")),
6314 "{format:?} {shape:?}: {err}"
6315 );
6316 }
6317 }
6318
6319 #[test]
6320 fn image_dims_from_shape_rejects_an_unproduced_combined_height() {
6321 // NV12 maps 480 -> 720 and 481 -> 722; nothing maps to 721, so the
6322 // shape is rejected rather than rounded to 480.
6323 let err = image_dims_from_shape(PixelFormat::Nv12, &[721, 640]).unwrap_err();
6324 assert!(
6325 matches!(err, Error::InvalidShape(ref m) if m.contains("combined plane height")),
6326 "{err}"
6327 );
6328 }
6329
6330 #[test]
6331 fn allocation_shape_per_layout() {
6332 assert_eq!(
6333 PixelFormat::Rgb.allocation_shape(640, 480),
6334 Some(vec![480, 640, 3])
6335 );
6336 assert_eq!(
6337 PixelFormat::Grey.allocation_shape(640, 480),
6338 Some(vec![480, 640, 1])
6339 );
6340 assert_eq!(
6341 PixelFormat::Nv12.allocation_shape(640, 480),
6342 Some(vec![720, 640])
6343 );
6344 // Odd height: combined-plane height is `481 + ceil(481/2)` = 481 + 241
6345 // = 722 rows. Logical height is recovered as `722 * 2 / 3` = 481.
6346 assert_eq!(
6347 PixelFormat::Nv12.allocation_shape(640, 481),
6348 Some(vec![722, 640])
6349 );
6350 // Odd width: shape carries the LOGICAL width (641).
6351 // The 64-aligned stride (>= 642) is stored separately on the Tensor.
6352 assert_eq!(
6353 PixelFormat::Nv12.allocation_shape(641, 480),
6354 Some(vec![720, 641])
6355 );
6356 // NV16 odd width: same — logical width in shape, stride separate.
6357 assert_eq!(
6358 PixelFormat::Nv16.allocation_shape(641, 480),
6359 Some(vec![960, 641])
6360 );
6361 assert_eq!(
6362 PixelFormat::PlanarRgb.allocation_shape(640, 480),
6363 Some(vec![3, 480, 640])
6364 );
6365 assert_eq!(
6366 PixelFormat::Nv16.allocation_shape(640, 480),
6367 Some(vec![960, 640])
6368 );
6369 }
6370
6371 #[test]
6372 fn raw_tensor_has_no_format() {
6373 let t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
6374 assert!(t.format().is_none());
6375 assert!(t.width().is_none());
6376 assert!(t.height().is_none());
6377 assert!(!t.is_multiplane());
6378 assert!(t.chroma().is_none());
6379 }
6380
6381 #[test]
6382 fn image_tensor_packed() {
6383 let t = Tensor::<u8>::image(
6384 640,
6385 480,
6386 PixelFormat::Rgba,
6387 None,
6388 crate::CpuAccess::ReadWrite,
6389 )
6390 .unwrap();
6391 assert_eq!(t.format(), Some(PixelFormat::Rgba));
6392 assert_eq!(t.width(), Some(640));
6393 assert_eq!(t.height(), Some(480));
6394 assert_eq!(t.shape(), &[480, 640, 4]);
6395 assert!(!t.is_multiplane());
6396 }
6397
6398 #[test]
6399 fn image_tensor_planar() {
6400 let t = Tensor::<u8>::image(
6401 640,
6402 480,
6403 PixelFormat::PlanarRgb,
6404 None,
6405 crate::CpuAccess::ReadWrite,
6406 )
6407 .unwrap();
6408 assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6409 assert_eq!(t.width(), Some(640));
6410 assert_eq!(t.height(), Some(480));
6411 assert_eq!(t.shape(), &[3, 480, 640]);
6412 }
6413
6414 #[test]
6415 #[cfg(target_os = "macos")]
6416 fn image_tensor_dma_non_aligned_packed_width_pads_zero_copy() {
6417 // Declares this test as an fd-opener; see FD_LOCK.
6418 let _lock = crate::tests::fd_lock_shared();
6419 // RGBA u8 at width=4 → 4*4 = 16 bytes/row, not 64-byte aligned. RGBA has
6420 // a real IOSurface FourCC, so an explicit `Some(TensorMemory::DmaBuf)`
6421 // request now allocates a padded image IOSurface (64-aligned
6422 // `bytes_per_row`) and records the stride — a fully zero-copy buffer GL
6423 // can bind and the CPU can map via the strided path. (Previously this
6424 // failed loudly to avoid an 'L008' byte-bag downgrade; with a real
6425 // FourCC surface that concern no longer applies.)
6426 let t = Tensor::<u8>::image(
6427 4,
6428 4,
6429 PixelFormat::Rgba,
6430 Some(TensorMemory::DmaBuf),
6431 crate::CpuAccess::ReadWrite,
6432 )
6433 .expect("padded RGBA IOSurface should allocate");
6434 assert_eq!(t.format(), Some(PixelFormat::Rgba));
6435 assert_eq!(t.width(), Some(4));
6436 assert_eq!(t.height(), Some(4));
6437 let stride = t.effective_row_stride().expect("stride");
6438 assert_eq!(stride % 64, 0, "padded to 64-byte row alignment");
6439 assert!(stride >= 16);
6440 // A CPU map exposes the full padded surface for strided iteration.
6441 let m = t.map().expect("strided IOSurface map");
6442 assert_eq!(m.as_slice().len(), stride * 4);
6443 }
6444
6445 /// `per_pixel_bytes` that doesn't divide 64 evenly (e.g. RGB u8 with
6446 /// 3 B/pixel) makes a "Pad width to N" suggestion structurally
6447 /// impossible — there is no integer width whose `width * 3` is a
6448 /// multiple of 64. The error must still fire (no silent SHM
6449 /// fallback for explicit-DMA requests) and must spell out the
6450 /// alignment requirement; it just omits the misleading "pad to N"
6451 /// hint instead of printing a number whose row pitch still won't
6452 /// align.
6453 #[test]
6454 #[cfg(target_os = "macos")]
6455 fn image_tensor_dma_rejects_indivisible_pixel_pitch_without_pad_hint() {
6456 // Declares this test as an fd-opener; see FD_LOCK.
6457 let _lock = crate::tests::fd_lock_shared();
6458 // Width=10 RGB f32 → 120 B/row, not 64-byte aligned, and (Rgb,
6459 // F32) has no IOSurface mapping so the padded-stride tolerance
6460 // does not apply. The next 64-multiple (128 B) isn't an integer
6461 // multiple of 12 B/pixel, so the "pad width to N" hint can't
6462 // produce a valid number and must be omitted. (Rgb u8 used to be
6463 // this test's subject but now has a real RGBA8888 mapping with
6464 // padded-stride tolerance — see the test below.)
6465 let err = Tensor::<f32>::image(
6466 10,
6467 10,
6468 PixelFormat::Rgb,
6469 Some(TensorMemory::DmaBuf),
6470 crate::CpuAccess::ReadWrite,
6471 )
6472 .expect_err("RGB f32 with 12 B/pixel and non-aligned width must be rejected");
6473 match err {
6474 Error::InvalidArgument(msg) => {
6475 assert!(
6476 msg.contains("64-byte aligned"),
6477 "error must still name the alignment requirement: {msg}"
6478 );
6479 assert!(
6480 !msg.contains("Pad width"),
6481 "indivisible per-pixel pitch makes a width suggestion impossible; \
6482 hint must be omitted, got: {msg}"
6483 );
6484 assert!(
6485 msg.contains("memory=None") && msg.contains("TensorMemory::Mem"),
6486 "error must still list the always-applicable alternatives: {msg}"
6487 );
6488 }
6489 other => panic!("expected InvalidArgument, got {other:?}"),
6490 }
6491 }
6492
6493 #[test]
6494 #[cfg(target_os = "macos")]
6495 fn image_tensor_dma_packed_rgb_u8_contract() {
6496 // Declares this test as an fd-opener; see FD_LOCK.
6497 let _lock = crate::tests::fd_lock_shared();
6498 // Packed RGB u8 @Dma is a designed RGBA8888 mapping at
6499 // (W*3/4, H) — the INT8 NPU input layout, shared with Android.
6500 // width%4 != 0 cannot form whole texels → loud InvalidArgument…
6501 let err = Tensor::<u8>::image(
6502 10,
6503 10,
6504 PixelFormat::Rgb,
6505 Some(TensorMemory::DmaBuf),
6506 crate::CpuAccess::ReadWrite,
6507 )
6508 .expect_err("Rgb u8 width%4!=0 must be rejected");
6509 assert!(
6510 matches!(&err, Error::InvalidArgument(m) if m.contains("width%4==0")),
6511 "got {err:?}"
6512 );
6513 // …width%4 == 0 with a non-64-aligned pitch allocates PADDED
6514 // (36 B rows → 64 B surface pitch, recorded on the tensor)…
6515 let t = Tensor::<u8>::image(
6516 12,
6517 4,
6518 PixelFormat::Rgb,
6519 Some(TensorMemory::DmaBuf),
6520 crate::CpuAccess::ReadWrite,
6521 )
6522 .expect("width 12 Rgb u8 must allocate padded");
6523 assert_eq!(t.memory(), TensorMemory::DmaBuf);
6524 assert!(
6525 t.row_stride()
6526 .is_some_and(|s| s >= 64 && s.is_multiple_of(64)),
6527 "padded pitch must be recorded: {:?}",
6528 t.row_stride()
6529 );
6530 // …and the aligned model-input width stays flat (640*3 = 1920 is
6531 // 64-aligned → no recorded stride, the buffer IS [H, W, 3]).
6532 let t = Tensor::<u8>::image(
6533 640,
6534 8,
6535 PixelFormat::Rgb,
6536 Some(TensorMemory::DmaBuf),
6537 crate::CpuAccess::ReadWrite,
6538 )
6539 .expect("width 640 Rgb u8 must allocate flat");
6540 assert_eq!(t.row_stride(), None);
6541 // I8 shares the layout (INT8 shader bias, not a format change).
6542 let t = Tensor::<i8>::image(
6543 640,
6544 8,
6545 PixelFormat::Rgb,
6546 Some(TensorMemory::DmaBuf),
6547 crate::CpuAccess::ReadWrite,
6548 )
6549 .expect("Rgb i8 shares the RGBA8888 mapping");
6550 assert_eq!(t.memory(), TensorMemory::DmaBuf);
6551 }
6552
6553 #[test]
6554 #[cfg(target_os = "macos")]
6555 fn image_tensor_dma_planar_f16_alignment() {
6556 // Declares this test as an fd-opener; see FD_LOCK.
6557 let _lock = crate::tests::fd_lock_shared();
6558 // PlanarRgb F16 uses single-channel row pitch (width * 2 bytes).
6559 // Width=16 → 32 bytes/row (not aligned); width=32 → 64 bytes/row (aligned).
6560 let err = Tensor::<half::f16>::image(
6561 16,
6562 16,
6563 PixelFormat::PlanarRgb,
6564 Some(TensorMemory::DmaBuf),
6565 crate::CpuAccess::ReadWrite,
6566 )
6567 .expect_err("width=16 PlanarRgb F16 is 32-byte row, must reject");
6568 assert!(matches!(err, Error::InvalidArgument(_)), "got {err:?}");
6569 // 32 wide should work.
6570 let t = Tensor::<half::f16>::image(
6571 32,
6572 8,
6573 PixelFormat::PlanarRgb,
6574 Some(TensorMemory::DmaBuf),
6575 crate::CpuAccess::ReadWrite,
6576 )
6577 .expect("width=32 PlanarRgb F16 is 64-byte row, must succeed");
6578 assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6579 }
6580
6581 #[test]
6582 fn image_tensor_semi_planar_contiguous() {
6583 let t = Tensor::<u8>::image(
6584 640,
6585 480,
6586 PixelFormat::Nv12,
6587 None,
6588 crate::CpuAccess::ReadWrite,
6589 )
6590 .unwrap();
6591 assert_eq!(t.format(), Some(PixelFormat::Nv12));
6592 assert_eq!(t.width(), Some(640));
6593 assert_eq!(t.height(), Some(480));
6594 // NV12: H*3/2 = 720
6595 assert_eq!(t.shape(), &[720, 640]);
6596 assert!(!t.is_multiplane());
6597 }
6598
6599 #[test]
6600 #[cfg(target_os = "linux")]
6601 fn image_tensor_with_stride_preserves_logical_width() {
6602 // Declares this test as an fd-opener; see FD_LOCK.
6603 let _lock = crate::tests::fd_lock_shared();
6604 // Skip if DMA not available (e.g. sandboxed CI lacking dma_heap access).
6605 if !is_dma_available() {
6606 crate::test_support::report_skip("DMA heap not available");
6607 return;
6608 }
6609 // 3004×1688 RGBA8: natural pitch 12016, padded to 12032 (64-aligned).
6610 let stride = 12032;
6611 let t = Tensor::<u8>::image_with_stride(
6612 3004,
6613 1688,
6614 PixelFormat::Rgba,
6615 stride,
6616 Some(TensorMemory::DmaBuf),
6617 crate::CpuAccess::ReadWrite,
6618 )
6619 .unwrap();
6620 // Logical dimensions unchanged by padding — this is the contract.
6621 assert_eq!(t.width(), Some(3004));
6622 assert_eq!(t.height(), Some(1688));
6623 assert_eq!(t.shape(), &[1688, 3004, 4]);
6624 // Stride is carried separately and reports the padded pitch.
6625 assert_eq!(t.effective_row_stride(), Some(stride));
6626 // Buffer is sized to stride × height so the full padded layout fits,
6627 // and CPU map() works for self-allocated strided DMA tensors.
6628 use crate::TensorMapTrait;
6629 {
6630 let map = t.map().unwrap();
6631 assert!(
6632 map.as_slice().len() >= stride * 1688,
6633 "mapped buffer {} bytes < expected {}",
6634 map.as_slice().len(),
6635 stride * 1688
6636 );
6637 }
6638 // CPU write access works too — iterate rows using the padded stride,
6639 // touch only the active `width × bpp` region, verify it round-trips.
6640 {
6641 let mut map = t.map().unwrap();
6642 let slice = map.as_mut_slice();
6643 for y in 0..1688 {
6644 let row_start = y * stride;
6645 for x in 0..3004 {
6646 let p = row_start + x * 4;
6647 slice[p] = (y & 0xFF) as u8;
6648 slice[p + 1] = (x & 0xFF) as u8;
6649 slice[p + 2] = 0x42;
6650 slice[p + 3] = 0xFF;
6651 }
6652 }
6653 }
6654 {
6655 let map = t.map().unwrap();
6656 let slice = map.as_slice();
6657 // Sample a few pixels to confirm the round-trip.
6658 assert_eq!(slice[0], 0x00);
6659 assert_eq!(slice[1], 0x00);
6660 assert_eq!(slice[2], 0x42);
6661 assert_eq!(slice[3], 0xFF);
6662 let mid = 100 * stride + 50 * 4;
6663 assert_eq!(slice[mid], 100);
6664 assert_eq!(slice[mid + 1], 50);
6665 assert_eq!(slice[mid + 2], 0x42);
6666 }
6667 }
6668
6669 #[test]
6670 #[cfg(target_os = "linux")]
6671 fn image_tensor_foreign_strided_map_is_bounds_checked_not_refused() {
6672 // An imported (foreign) DMA-BUF with a row stride used to be refused
6673 // outright. That changed deliberately: the cross-package capsule
6674 // protocol reconstructs an imported alias of a HAL-owned Dma tensor,
6675 // and refusing every strided CPU map broke the flagship
6676 // create_image() -> decode_file_into() -> convert() pipeline on Linux.
6677 //
6678 // What replaced the refusal is a bounds check against `buf_size`,
6679 // which for an imported fd is `fstat`-derived kernel truth rather
6680 // than the caller's claim. So the property worth testing is no longer
6681 // "foreign strided maps fail" but "they succeed exactly when they
6682 // fit" -- which is what actually keeps the mapping sound.
6683 //
6684 // This test previously asserted the old refusal and went red on every
6685 // board with a dma-heap when the behaviour changed, unnoticed because
6686 // it needs both Linux and a dma-heap: it skips on macOS and on Orin
6687 // (nvmap, no CONFIG_DMABUF_HEAPS).
6688 if !is_dma_available() {
6689 crate::test_support::report_skip("DMA heap not available");
6690 return;
6691 }
6692 let backing =
6693 Tensor::<u8>::new(&[240 * 320 * 4], Some(TensorMemory::DmaBuf), None).unwrap();
6694
6695 // In bounds: the natural stride for the declared image fits the
6696 // allocation, so the map must succeed.
6697 {
6698 let fd = backing.clone_fd().unwrap();
6699 let shape = [240usize, 320, 4];
6700 let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
6701 let mut t = Tensor::<u8>::wrap(storage);
6702 t.set_format(PixelFormat::Bgra).unwrap();
6703 t.set_row_stride(320 * 4).unwrap();
6704 assert!(
6705 t.map().is_ok(),
6706 "an in-bounds strided map of an imported DMA-BUF must be \
6707 accepted -- refusing it is what broke decode-into-GPU-tensor"
6708 );
6709 }
6710
6711 // Out of bounds: a stride the allocation cannot back must still be
6712 // refused, and the error must say so. This is the half that carries
6713 // the soundness.
6714 {
6715 let fd = backing.clone_fd().unwrap();
6716 let shape = [240usize, 320, 4];
6717 let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
6718 let mut t = Tensor::<u8>::wrap(storage);
6719 t.set_format(PixelFormat::Bgra).unwrap();
6720 // Double the pitch: 240 rows x 2560 bytes is twice the allocation.
6721 t.set_row_stride(320 * 4 * 2).unwrap();
6722 let err = t.map();
6723 assert!(
6724 matches!(err, Err(Error::InvalidOperation(_))),
6725 "a strided map larger than the DMA allocation must be refused"
6726 );
6727 }
6728 }
6729
6730 #[test]
6731 #[cfg(target_os = "linux")]
6732 fn image_tensor_with_stride_map_rejects_tampered_stride() {
6733 // Declares this test as an fd-opener; see FD_LOCK.
6734 let _lock = crate::tests::fd_lock_shared();
6735 // Round-3 PR feedback (C1): `set_row_stride` is public and only
6736 // validates `stride >= min_stride`, not that the new stride × height
6737 // fits the underlying buffer. A caller that tampers with the stride
6738 // after allocation must not be able to coerce `Tensor::map()` into
6739 // returning a slice larger than the backing mmap (that would be UB
6740 // in `DmaMap::as_slice`).
6741 if !is_dma_available() {
6742 crate::test_support::report_skip("DMA heap not available");
6743 return;
6744 }
6745 // Allocate a 640×480 RGBA8 padded canvas (stride = 3072 = 768 px).
6746 // Backing buffer is 3072 × 480 = 1,474,560 bytes.
6747 let mut t = Tensor::<u8>::image_with_stride(
6748 640,
6749 480,
6750 PixelFormat::Rgba,
6751 3072,
6752 Some(TensorMemory::DmaBuf),
6753 crate::CpuAccess::ReadWrite,
6754 )
6755 .unwrap();
6756 // Tamper: push the stride up to 4 × the original. This is >=
6757 // min_stride (2560), so `set_row_stride` accepts it.
6758 t.set_row_stride(12288).unwrap();
6759 // Map must now refuse — 12288 × 480 = 5,898,240 > 1,474,560.
6760 let err = t.map();
6761 assert!(
6762 matches!(err, Err(Error::InvalidOperation(_))),
6763 "map() with oversized stride must return InvalidOperation"
6764 );
6765 }
6766
6767 #[test]
6768 fn dma_tensor_new_with_byte_size_rejects_shape_overflow() {
6769 // Round-3 PR feedback (C3): shape.product() * sizeof(T) must use
6770 // checked arithmetic so a pathological shape can't wrap usize and
6771 // make the byte_size-vs-logical-size comparison incorrect.
6772 //
6773 // This test only exercises the overflow rejection path, which is
6774 // pure-Rust and doesn't touch dma_heap — safe to run on any target.
6775 #[cfg(target_os = "linux")]
6776 {
6777 let err = crate::dma::DmaTensor::<u64>::new_with_byte_size(
6778 &[usize::MAX, 2, 2],
6779 usize::MAX,
6780 None,
6781 );
6782 assert!(
6783 matches!(err, Err(Error::InvalidArgument(_))),
6784 "new_with_byte_size must detect shape.product() overflow"
6785 );
6786 }
6787 }
6788
6789 #[test]
6790 #[cfg(target_os = "linux")]
6791 fn image_tensor_with_stride_rejects_too_small_stride() {
6792 // Declares this test as an fd-opener; see FD_LOCK.
6793 let _lock = crate::tests::fd_lock_shared();
6794 // 640×480 RGBA8 natural pitch = 2560, request 2400 → should error.
6795 let err = Tensor::<u8>::image_with_stride(
6796 640,
6797 480,
6798 PixelFormat::Rgba,
6799 2400,
6800 Some(TensorMemory::DmaBuf),
6801 crate::CpuAccess::ReadWrite,
6802 );
6803 assert!(matches!(err, Err(Error::InvalidArgument(_))));
6804 }
6805
6806 #[test]
6807 #[cfg(target_os = "linux")]
6808 fn image_tensor_with_stride_rejects_non_packed() {
6809 // Declares this test as an fd-opener; see FD_LOCK.
6810 let _lock = crate::tests::fd_lock_shared();
6811 // NV12 is SemiPlanar → not supported. (Linux-only because
6812 // `TensorMemory::DmaBuf` itself is a Linux-only enum variant.)
6813 let err = Tensor::<u8>::image_with_stride(
6814 640,
6815 480,
6816 PixelFormat::Nv12,
6817 640,
6818 Some(TensorMemory::DmaBuf),
6819 crate::CpuAccess::ReadWrite,
6820 );
6821 assert!(matches!(err, Err(Error::NotImplemented(_))));
6822 }
6823
6824 #[test]
6825 fn set_format_valid() {
6826 let mut t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
6827 assert!(t.format().is_none());
6828 t.set_format(PixelFormat::Rgb).unwrap();
6829 assert_eq!(t.format(), Some(PixelFormat::Rgb));
6830 assert_eq!(t.width(), Some(640));
6831 assert_eq!(t.height(), Some(480));
6832 }
6833
6834 #[test]
6835 fn set_format_invalid_shape() {
6836 let mut t = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
6837 // RGB expects 3 channels, not 4
6838 let err = t.set_format(PixelFormat::Rgb);
6839 assert!(err.is_err());
6840 // Original tensor is unmodified
6841 assert!(t.format().is_none());
6842 }
6843
6844 #[test]
6845 fn reshape_clears_format() {
6846 let mut t = Tensor::<u8>::image(
6847 640,
6848 480,
6849 PixelFormat::Rgba,
6850 None,
6851 crate::CpuAccess::ReadWrite,
6852 )
6853 .unwrap();
6854 assert_eq!(t.format(), Some(PixelFormat::Rgba));
6855 // Reshape to flat — format cleared
6856 t.reshape(&[480 * 640 * 4]).unwrap();
6857 assert!(t.format().is_none());
6858 }
6859
6860 #[test]
6861 fn from_planes_nv12() {
6862 let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
6863 let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
6864 let img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
6865 assert_eq!(img.format(), Some(PixelFormat::Nv12));
6866 assert!(img.is_multiplane());
6867 assert!(img.chroma().is_some());
6868 assert_eq!(img.width(), Some(640));
6869 assert_eq!(img.height(), Some(480));
6870 }
6871
6872 #[test]
6873 fn from_planes_rejects_non_semiplanar() {
6874 let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
6875 let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
6876 let err = Tensor::from_planes(y, uv, PixelFormat::Rgb);
6877 assert!(err.is_err());
6878 }
6879
6880 #[test]
6881 fn reshape_multiplane_errors() {
6882 let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
6883 let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
6884 let mut img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
6885 let err = img.reshape(&[480 * 640 + 240 * 640]);
6886 assert!(err.is_err());
6887 }
6888}
6889
6890#[cfg(test)]
6891mod compression_tests {
6892 use super::*;
6893
6894 #[test]
6895 fn desc_builder_roundtrips() {
6896 let desc = ImageDesc::new(640, 480, PixelFormat::Rgba, DType::U8)
6897 .with_memory(Some(TensorMemory::Mem))
6898 .with_access(CpuAccess::Read)
6899 .with_compression(Compression::Any);
6900 assert_eq!(desc.width(), 640);
6901 assert_eq!(desc.height(), 480);
6902 assert_eq!(desc.format(), PixelFormat::Rgba);
6903 assert_eq!(desc.dtype(), DType::U8);
6904 assert_eq!(desc.memory(), Some(TensorMemory::Mem));
6905 assert_eq!(desc.access(), CpuAccess::Read);
6906 assert_eq!(desc.compression(), Some(Compression::Any));
6907
6908 // Defaults: auto memory, hardware-only, no request.
6909 let plain = ImageDesc::new(2, 2, PixelFormat::Grey, DType::U8);
6910 assert_eq!(plain.memory(), None);
6911 assert_eq!(plain.access(), CpuAccess::None);
6912 assert_eq!(plain.compression(), None);
6913 }
6914
6915 #[test]
6916 fn desc_dtype_must_match_element_type() {
6917 let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::F32);
6918 match Tensor::<u8>::image_desc(&desc) {
6919 Err(Error::InvalidArgument(msg)) => assert!(msg.contains("dtype")),
6920 other => panic!("expected InvalidArgument, got {other:?}"),
6921 }
6922 }
6923
6924 #[test]
6925 fn compression_with_cpu_access_is_invalid() {
6926 let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
6927 .with_access(CpuAccess::ReadWrite)
6928 .with_compression(Compression::Any);
6929 match Tensor::<u8>::image_desc(&desc) {
6930 Err(Error::InvalidArgument(msg)) => assert!(msg.contains("CpuAccess::None")),
6931 other => panic!("expected InvalidArgument, got {other:?}"),
6932 }
6933 }
6934
6935 #[cfg(not(target_os = "android"))]
6936 #[test]
6937 fn scheme_request_off_android_is_not_implemented() {
6938 let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
6939 .with_compression(Compression::Scheme(CompressionScheme::Ubwc));
6940 match Tensor::<u8>::image_desc(&desc) {
6941 Err(Error::NotImplemented(msg)) => assert!(msg.contains("Ubwc")),
6942 other => panic!("expected NotImplemented, got {other:?}"),
6943 }
6944 }
6945
6946 #[cfg(not(target_os = "android"))]
6947 #[test]
6948 fn any_request_off_android_resolves_linear_and_counts() {
6949 let before = compression_fallback_count();
6950 let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
6951 .with_memory(Some(TensorMemory::Mem))
6952 .with_compression(Compression::Any);
6953 let t = Tensor::<u8>::image_desc(&desc).unwrap();
6954 assert_eq!(t.compression(), None);
6955 assert!(compression_fallback_count() > before);
6956 }
6957
6958 #[test]
6959 fn desc_without_request_matches_classic_constructor() {
6960 let desc = ImageDesc::new(32, 32, PixelFormat::Rgba, DType::U8)
6961 .with_memory(Some(TensorMemory::Mem))
6962 .with_access(CpuAccess::ReadWrite);
6963 let t = Tensor::<u8>::image_desc(&desc).unwrap();
6964 assert_eq!(t.compression(), None);
6965 assert_eq!(t.cpu_access(), CpuAccess::ReadWrite);
6966 assert_eq!(t.width(), Some(32));
6967 // Mappable exactly like the classic constructor's result.
6968 let m = t.map_read().unwrap();
6969 assert_eq!(m.as_slice().len(), 32 * 32 * 4);
6970 }
6971
6972 #[test]
6973 fn configure_image_preserves_compression_and_views_inherit() {
6974 // No host platform records a scheme, so emulate the recording to
6975 // pin the preserve/inherit semantics (the physical layout does
6976 // not change when the logical image is reconfigured).
6977 let mut t = Tensor::<u8>::image(
6978 64,
6979 64,
6980 PixelFormat::Rgba,
6981 Some(TensorMemory::Mem),
6982 CpuAccess::None,
6983 )
6984 .unwrap();
6985 t.compression = Some(CompressionScheme::Ubwc);
6986 t.configure_image(32, 32, PixelFormat::Rgba).unwrap();
6987 assert_eq!(t.compression(), Some(CompressionScheme::Ubwc));
6988 let view = t.subview(0, &[16, 32, 4]).unwrap();
6989 assert_eq!(view.compression(), Some(CompressionScheme::Ubwc));
6990 }
6991
6992 #[test]
6993 fn tensor_dyn_dispatches_desc_and_compression() {
6994 let desc = ImageDesc::new(16, 16, PixelFormat::Rgba, DType::U8)
6995 .with_memory(Some(TensorMemory::Mem))
6996 .with_access(CpuAccess::ReadWrite);
6997 let t = TensorDyn::image_desc(&desc).unwrap();
6998 assert_eq!(t.compression(), None);
6999 assert!(matches!(t, TensorDyn::U8(_)));
7000 }
7001}
7002
7003#[cfg(test)]
7004mod cpu_access_tests {
7005 use super::*;
7006
7007 #[test]
7008 fn covers_matrix() {
7009 use CpuAccess::*;
7010 // Every declaration covers a narrower or equal request…
7011 for a in [None, Read, Write, ReadWrite] {
7012 assert!(a.covers(None), "{a:?} must cover None");
7013 assert!(ReadWrite.covers(a), "ReadWrite must cover {a:?}");
7014 }
7015 assert!(Read.covers(Read));
7016 assert!(Write.covers(Write));
7017 // …and never a wider one.
7018 assert!(!None.covers(Read));
7019 assert!(!None.covers(Write));
7020 assert!(!Read.covers(Write));
7021 assert!(!Read.covers(ReadWrite));
7022 assert!(!Write.covers(Read));
7023 assert!(!Write.covers(ReadWrite));
7024 }
7025
7026 #[test]
7027 fn map_with_none_is_invalid() {
7028 let t = Tensor::<u8>::new(&[16], Some(TensorMemory::Mem), None).unwrap();
7029 match t.map_with(CpuAccess::None) {
7030 Err(Error::InvalidArgument(_)) => {}
7031 Err(other) => panic!("expected InvalidArgument, got {other:?}"),
7032 Ok(_) => panic!("map_with(CpuAccess::None) must not succeed"),
7033 }
7034 }
7035
7036 #[test]
7037 fn read_map_rejects_mutation_uniformly() {
7038 // Mem backend: map_read yields a working read view whose mutable
7039 // accessor panics (the uniform cross-backend contract).
7040 let t = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7041 t.map_mut().unwrap().as_mut_slice().copy_from_slice(&[7; 8]);
7042 let ro = t.map_read().unwrap();
7043 assert_eq!(ro.as_slice(), &[7; 8]);
7044 drop(ro);
7045 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7046 let mut ro = t.map_read().unwrap();
7047 let _ = ro.as_mut_slice();
7048 }));
7049 assert!(result.is_err(), "as_mut_slice through map_read must panic");
7050 }
7051
7052 #[test]
7053 fn write_and_rw_maps_stay_mutable() {
7054 let t = Tensor::<u8>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
7055 t.map_write()
7056 .unwrap()
7057 .as_mut_slice()
7058 .copy_from_slice(&[1; 4]);
7059 t.map().unwrap().as_mut_slice().copy_from_slice(&[2; 4]);
7060 assert_eq!(t.map_read().unwrap().as_slice(), &[2; 4]);
7061 }
7062
7063 /// The read-only IOSurface lock path: a `map_read` must observe data
7064 /// written through a prior read-write lock, and its unlock (which
7065 /// skips the cache flush) must not disturb subsequent reads.
7066 #[test]
7067 #[cfg(target_os = "macos")]
7068 fn iosurface_read_only_lock_roundtrip() {
7069 // Declares this test as an fd-opener; see FD_LOCK.
7070 let _lock = crate::tests::fd_lock_shared();
7071 let Ok(t) = Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None) else {
7072 crate::test_support::report_skip("IOSurface unavailable");
7073 return;
7074 };
7075 {
7076 let mut m = t.map_mut().unwrap();
7077 for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7078 *b = (i * 3) as u8;
7079 }
7080 }
7081 for _ in 0..2 {
7082 let ro = t.map_read().unwrap();
7083 for (i, b) in ro.as_slice().iter().enumerate() {
7084 assert_eq!(*b, (i * 3) as u8, "byte {i} through read-only lock");
7085 }
7086 }
7087 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7088 let mut ro = t.map_read().unwrap();
7089 let _ = ro.as_mut_slice();
7090 }));
7091 assert!(result.is_err(), "IOSurface read map must reject mutation");
7092 }
7093}
7094
7095#[cfg(test)]
7096mod tests {
7097 #[cfg(target_os = "linux")]
7098 use nix::unistd::{access, AccessFlags};
7099 #[cfg(target_os = "linux")]
7100 use std::io::Write as _;
7101 use std::sync::{PoisonError, RwLock};
7102
7103 use super::*;
7104
7105 #[ctor::ctor(unsafe)]
7106 fn init() {
7107 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
7108 }
7109
7110 /// Macro to get the current function name for logging in tests.
7111 #[cfg(target_os = "linux")]
7112 macro_rules! function {
7113 () => {{
7114 fn f() {}
7115 fn type_name_of<T>(_: T) -> &'static str {
7116 std::any::type_name::<T>()
7117 }
7118 let name = type_name_of(f);
7119
7120 // Find and cut the rest of the path
7121 match &name[..name.len() - 3].rfind(':') {
7122 Some(pos) => &name[pos + 1..name.len() - 3],
7123 None => &name[..name.len() - 3],
7124 }
7125 }};
7126 }
7127
7128 #[test]
7129 #[cfg(target_os = "linux")]
7130 fn test_tensor() {
7131 let _lock = crate::tests::fd_lock_shared();
7132 let shape = vec![1];
7133 let tensor = DmaTensor::<f32>::new(&shape, Some("dma_tensor"));
7134 let dma_enabled = tensor.is_ok();
7135
7136 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7137 // Auto-select priority is Dma > Mem; Shm is never auto-selected.
7138 match dma_enabled {
7139 true => assert_eq!(tensor.memory(), TensorMemory::DmaBuf),
7140 false => assert_eq!(tensor.memory(), TensorMemory::Mem),
7141 }
7142 }
7143
7144 #[test]
7145 #[cfg(any(target_os = "macos", target_os = "ios"))]
7146 fn test_tensor() {
7147 let shape = vec![1];
7148 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7149 // macOS/iOS auto-fallback chain: IOSurface (Dma) → Mem. Healthy systems
7150 // return Dma; Mem only appears under memory pressure or sandboxed
7151 // contexts where IOSurfaceCreate fails. Shm is never auto-selected.
7152 let m = tensor.memory();
7153 assert!(
7154 matches!(m, TensorMemory::DmaBuf | TensorMemory::Mem),
7155 "Unexpected auto-fallback result on macOS/iOS: {m:?}"
7156 );
7157 }
7158
7159 #[test]
7160 #[cfg(all(
7161 unix,
7162 not(any(target_os = "linux", target_os = "macos", target_os = "ios"))
7163 ))]
7164 fn test_tensor() {
7165 let shape = vec![1];
7166 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7167 // Other Unix (BSD): no DMA, so auto-selection is Mem (Shm is
7168 // explicit-only, never auto-selected).
7169 assert_eq!(tensor.memory(), TensorMemory::Mem);
7170 }
7171
7172 #[test]
7173 #[cfg(not(unix))]
7174 fn test_tensor() {
7175 let shape = vec![1];
7176 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7177 assert_eq!(tensor.memory(), TensorMemory::Mem);
7178 }
7179
7180 #[test]
7181 #[cfg(target_os = "linux")]
7182 fn test_dma_tensor() {
7183 let _lock = crate::tests::fd_lock_shared();
7184 match access(
7185 "/dev/dma_heap/linux,cma",
7186 AccessFlags::R_OK | AccessFlags::W_OK,
7187 ) {
7188 Ok(_) => println!("/dev/dma_heap/linux,cma is available"),
7189 Err(_) => match access(
7190 "/dev/dma_heap/system",
7191 AccessFlags::R_OK | AccessFlags::W_OK,
7192 ) {
7193 Ok(_) => println!("/dev/dma_heap/system is available"),
7194 Err(e) => {
7195 writeln!(
7196 &mut std::io::stdout(),
7197 "[WARNING] DMA Heap is unavailable: {e}"
7198 )
7199 .unwrap();
7200 return;
7201 }
7202 },
7203 }
7204
7205 let shape = vec![2, 3, 4];
7206 let tensor =
7207 DmaTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
7208
7209 const DUMMY_VALUE: f32 = 12.34;
7210
7211 assert_eq!(tensor.memory(), TensorMemory::DmaBuf);
7212 assert_eq!(tensor.name(), "test_tensor");
7213 assert_eq!(tensor.shape(), &shape);
7214 assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
7215 assert_eq!(tensor.len(), 2 * 3 * 4);
7216
7217 {
7218 let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
7219 tensor_map.fill(42.0);
7220 assert!(tensor_map.iter().all(|&x| x == 42.0));
7221 }
7222
7223 {
7224 let shared = Tensor::<f32>::from_fd(
7225 tensor
7226 .clone_fd()
7227 .expect("Failed to duplicate tensor file descriptor"),
7228 &shape,
7229 Some("test_tensor_shared"),
7230 )
7231 .expect("Failed to create tensor from fd");
7232
7233 assert_eq!(shared.memory(), TensorMemory::DmaBuf);
7234 assert_eq!(shared.name(), "test_tensor_shared");
7235 assert_eq!(shared.shape(), &shape);
7236
7237 let mut tensor_map = shared.map().expect("Failed to map DMA memory from fd");
7238 tensor_map.fill(DUMMY_VALUE);
7239 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7240 }
7241
7242 {
7243 let tensor_map = tensor.map().expect("Failed to map DMA memory");
7244 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7245 }
7246
7247 let mut tensor = DmaTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
7248 assert_eq!(tensor.shape(), &shape);
7249 let new_shape = vec![3, 4, 4];
7250 assert!(
7251 tensor.reshape(&new_shape).is_err(),
7252 "Reshape should fail due to size mismatch"
7253 );
7254 assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
7255
7256 let new_shape = vec![2, 3, 4];
7257 tensor.reshape(&new_shape).expect("Reshape should succeed");
7258 assert_eq!(
7259 tensor.shape(),
7260 &new_shape,
7261 "Shape should be updated after successful reshape"
7262 );
7263
7264 {
7265 let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
7266 tensor_map.fill(1);
7267 assert!(tensor_map.iter().all(|&x| x == 1));
7268 }
7269
7270 {
7271 let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
7272 tensor_map[2] = 42;
7273 assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
7274 assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
7275 }
7276 }
7277
7278 #[test]
7279 #[cfg(unix)]
7280 fn test_shm_tensor() {
7281 let _lock = crate::tests::fd_lock_shared();
7282 if !is_shm_available() {
7283 log::warn!("SKIPPED: test_shm_tensor - SHM allocation unavailable on this platform");
7284 return;
7285 }
7286 let shape = vec![2, 3, 4];
7287 let tensor =
7288 ShmTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
7289 assert_eq!(tensor.shape(), &shape);
7290 assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
7291 assert_eq!(tensor.name(), "test_tensor");
7292
7293 const DUMMY_VALUE: f32 = 12.34;
7294 {
7295 let mut tensor_map = tensor.map().expect("Failed to map shared memory");
7296 tensor_map.fill(42.0);
7297 assert!(tensor_map.iter().all(|&x| x == 42.0));
7298 }
7299
7300 {
7301 let shared = Tensor::<f32>::from_fd(
7302 tensor
7303 .clone_fd()
7304 .expect("Failed to duplicate tensor file descriptor"),
7305 &shape,
7306 Some("test_tensor_shared"),
7307 )
7308 .expect("Failed to create tensor from fd");
7309
7310 assert_eq!(shared.memory(), TensorMemory::Shm);
7311 assert_eq!(shared.name(), "test_tensor_shared");
7312 assert_eq!(shared.shape(), &shape);
7313
7314 let mut tensor_map = shared.map().expect("Failed to map shared memory from fd");
7315 tensor_map.fill(DUMMY_VALUE);
7316 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7317 }
7318
7319 {
7320 let tensor_map = tensor.map().expect("Failed to map shared memory");
7321 assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7322 }
7323
7324 let mut tensor = ShmTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
7325 assert_eq!(tensor.shape(), &shape);
7326 let new_shape = vec![3, 4, 4];
7327 assert!(
7328 tensor.reshape(&new_shape).is_err(),
7329 "Reshape should fail due to size mismatch"
7330 );
7331 assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
7332
7333 let new_shape = vec![2, 3, 4];
7334 tensor.reshape(&new_shape).expect("Reshape should succeed");
7335 assert_eq!(
7336 tensor.shape(),
7337 &new_shape,
7338 "Shape should be updated after successful reshape"
7339 );
7340
7341 {
7342 let mut tensor_map = tensor.map().expect("Failed to map shared memory");
7343 tensor_map.fill(1);
7344 assert!(tensor_map.iter().all(|&x| x == 1));
7345 }
7346
7347 {
7348 let mut tensor_map = tensor.map().expect("Failed to map shared memory");
7349 tensor_map[2] = 42;
7350 assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
7351 assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
7352 }
7353 }
7354
7355 #[test]
7356 fn mem_subview_partitions_parent_buffer() {
7357 // One heap [2,4] u8 parent (8 bytes). Two [1,4] sub-views at byte
7358 // offsets 0 and 4 must share the parent allocation (zero-copy) and be
7359 // independently writable: view 0 owns bytes [0,4), view 1 owns [4,8).
7360 // Today this is impossible — heap offset is rejected and there is no
7361 // shared sub-view constructor.
7362 let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Mem), None).unwrap();
7363 let view0 = parent.subview(0, &[1, 4]).expect("subview at offset 0");
7364 let view1 = parent.subview(4, &[1, 4]).expect("subview at offset 4");
7365
7366 view1
7367 .map()
7368 .unwrap()
7369 .as_mut_slice()
7370 .copy_from_slice(&[10, 20, 30, 40]);
7371 view0
7372 .map()
7373 .unwrap()
7374 .as_mut_slice()
7375 .copy_from_slice(&[1, 2, 3, 4]);
7376
7377 // Each view sees only its own window.
7378 assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
7379 assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
7380 // The parent buffer is correctly partitioned (shared, zero-copy).
7381 assert_eq!(
7382 parent.map().unwrap().as_slice(),
7383 &[1, 2, 3, 4, 10, 20, 30, 40]
7384 );
7385 }
7386
7387 #[test]
7388 fn batch_partitions_leading_dim() {
7389 // Raw [4,2,2,3] u8 batched tensor: 4 elements of 12 bytes each. batch(n)
7390 // yields element n at offset n*12, sharing the parent buffer (zero-copy).
7391 let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7392 for i in 0..4u8 {
7393 let e = parent.batch(i as usize).expect("batch element");
7394 assert_eq!(e.shape(), &[2, 2, 3]);
7395 // A batch element shares the parent's BufferIdentity.
7396 assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
7397 for b in e.map().unwrap().as_mut_slice() {
7398 *b = i + 1;
7399 }
7400 }
7401 // Each element occupies its own 12-byte band of the parent.
7402 let whole = parent.map().unwrap();
7403 let s = whole.as_slice();
7404 for i in 0..4usize {
7405 assert!(
7406 s[i * 12..(i + 1) * 12].iter().all(|&b| b == (i as u8 + 1)),
7407 "band {i} not partitioned: {:?}",
7408 &s[i * 12..(i + 1) * 12]
7409 );
7410 }
7411 }
7412
7413 #[test]
7414 fn view_origin_snapshots_parent_and_composes() {
7415 // view() on a whole image snapshots the parent dims + the view's origin.
7416 let parent = Tensor::<u8>::image(
7417 100,
7418 80,
7419 PixelFormat::Rgba,
7420 Some(TensorMemory::Mem),
7421 crate::CpuAccess::ReadWrite,
7422 )
7423 .unwrap();
7424 assert_eq!(
7425 parent.view_origin(),
7426 None,
7427 "whole tensor has no view_origin"
7428 );
7429 let v = parent.view(Region::new(10, 20, 30, 40)).unwrap();
7430 assert_eq!(
7431 v.view_origin(),
7432 Some(ViewOrigin {
7433 parent_width: 100,
7434 parent_height: 80,
7435 parent_row_stride: 100 * 4, // tight RGBA pitch
7436 x: 10,
7437 y: 20
7438 })
7439 );
7440 // A view of a view keeps the ROOT parent and accumulates the origin.
7441 let v2 = v.view(Region::new(5, 5, 10, 10)).unwrap();
7442 assert_eq!(
7443 v2.view_origin(),
7444 Some(ViewOrigin {
7445 parent_width: 100,
7446 parent_height: 80,
7447 parent_row_stride: 100 * 4,
7448 x: 15,
7449 y: 25
7450 }),
7451 "nested view composes onto the root parent"
7452 );
7453 }
7454
7455 #[test]
7456 fn view_origin_none_for_raw_batch() {
7457 // A raw (unformatted) batched tensor has no pixel geometry, so batch()
7458 // leaves view_origin None (the per-slot path, not the one-import pivot).
7459 let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7460 assert_eq!(parent.batch(2).unwrap().view_origin(), None);
7461 }
7462
7463 #[test]
7464 fn batch_rejects_out_of_bounds_index() {
7465 let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7466 match parent.batch(4) {
7467 Err(Error::BatchIndexOutOfBounds { index, batch }) => {
7468 assert_eq!((index, batch), (4, 4));
7469 }
7470 other => panic!("expected BatchIndexOutOfBounds, got {other:?}"),
7471 }
7472 }
7473
7474 #[test]
7475 fn batch_zero_on_unit_n_is_whole() {
7476 // N == 1: batch(0) is the whole per-element block at offset 0 (no plane_offset).
7477 let parent = Tensor::<u8>::new(&[1, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7478 let e = parent.batch(0).unwrap();
7479 assert_eq!(e.shape(), &[2, 2, 3]);
7480 assert_eq!(e.plane_offset(), None);
7481 assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
7482 }
7483
7484 #[test]
7485 fn mem_subview_rejects_unaligned_offset() {
7486 // f32 has align 4; a byte offset of 2 cannot back a valid `*const f32`.
7487 let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7488 assert!(parent.subview(2, &[1]).is_err());
7489 // A correctly aligned offset is accepted.
7490 assert!(parent.subview(4, &[1]).is_ok());
7491 }
7492
7493 #[test]
7494 fn mem_subview_rejects_out_of_bounds() {
7495 let parent = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7496 // offset 6 + 4 bytes = 10 exceeds the 8-byte allocation.
7497 assert!(parent.subview(6, &[4]).is_err());
7498 }
7499
7500 /// Regression guard for the `TensorTrait::view` promotion (R2): a `subview`
7501 /// must share the parent's `BufferIdentity` on **every** backend, not mint a
7502 /// fresh one. Identity-keyed caches (the GL EGLImage import) rely on this to
7503 /// treat offset-distinct windows of one buffer as a single import; a fresh
7504 /// identity would silently break that and regress zero-copy import reuse.
7505 ///
7506 /// Runs each backend that can be allocated without a GPU/GL context on the
7507 /// test host: `Mem` (always); `Shm` (when POSIX shm is available); the
7508 /// platform-native zero-copy buffer `Dma` (DMA-BUF on Linux / IOSurface on
7509 /// macOS, when available). `Pbo` shares its identity the same way (see
7510 /// `pbo.rs` `view`) but needs a live GL context, so it is exercised by the
7511 /// image-crate GL tests rather than here.
7512 #[test]
7513 fn subview_shares_buffer_identity_all_backends() {
7514 // Declares this test as an fd-opener; see FD_LOCK.
7515 let _lock = crate::tests::fd_lock_shared();
7516 // u8 has align 1, so every byte offset is valid for the alignment check;
7517 // this isolates the identity-sharing contract from alignment concerns.
7518 // `window` is the view shape; a formatted parent validates it against
7519 // its own format, so an image parent needs an image-shaped window.
7520 let assert_parent_shares = |parent: Tensor<u8>, window: &[usize], label: &str| {
7521 let parent_id = parent.buffer_identity().id();
7522 // Two offset-distinct windows must both carry the parent's identity.
7523 let v0 = parent
7524 .subview(0, window)
7525 .unwrap_or_else(|e| panic!("{label}: subview(0) failed: {e:?}"));
7526 let v1 = parent
7527 .subview(16, window)
7528 .unwrap_or_else(|e| panic!("{label}: subview(16) failed: {e:?}"));
7529 assert_eq!(
7530 v0.buffer_identity().id(),
7531 parent_id,
7532 "{label}: subview(0) minted a fresh BufferIdentity"
7533 );
7534 assert_eq!(
7535 v1.buffer_identity().id(),
7536 parent_id,
7537 "{label}: subview(16) minted a fresh BufferIdentity"
7538 );
7539 };
7540 let assert_shares = |memory: TensorMemory, label: &str| {
7541 let parent = Tensor::<u8>::new(&[64], Some(memory), None)
7542 .unwrap_or_else(|e| panic!("{label}: parent alloc failed: {e:?}"));
7543 assert_parent_shares(parent, &[16], label);
7544 };
7545
7546 assert_shares(TensorMemory::Mem, "Mem");
7547
7548 #[cfg(unix)]
7549 if crate::is_shm_available() {
7550 assert_shares(TensorMemory::Shm, "Shm");
7551 }
7552
7553 // Dma == DMA-BUF on Linux, IOSurface on macOS, AHardwareBuffer on
7554 // Android; same public variant.
7555 #[cfg(not(target_os = "windows"))]
7556 if crate::is_gpu_buffer_available() {
7557 assert_shares(TensorMemory::DmaBuf, "Dma");
7558 }
7559 // Windows' Dma is an image-formatted D3D11 texture, which
7560 // `Tensor::new` deliberately refuses -- allocate it the only way it
7561 // can be allocated and hold it to the same contract.
7562 #[cfg(target_os = "windows")]
7563 if crate::is_gpu_buffer_available() {
7564 let parent = Tensor::<u8>::image(
7565 64,
7566 1,
7567 PixelFormat::Grey,
7568 Some(TensorMemory::DmaBuf),
7569 CpuAccess::ReadWrite,
7570 )
7571 .expect("D3D11 texture alloc failed");
7572 assert_parent_shares(parent, &[1, 16, 1], "Dma");
7573 }
7574 }
7575
7576 /// A sub-view records a plane offset, and on Windows the D3D11 storage
7577 /// already applied that offset itself when the view was made -- so the
7578 /// map's plane-offset gate must accept it, exactly as it does for the
7579 /// other zero-copy backings. It used to refuse, with a message that named
7580 /// every backend but this one.
7581 #[cfg(target_os = "windows")]
7582 #[test]
7583 fn subview_of_a_texture_tensor_maps_at_its_offset() {
7584 if !crate::is_gpu_buffer_available() {
7585 return;
7586 }
7587 let t = Tensor::<u8>::image(
7588 32,
7589 8,
7590 PixelFormat::Rgba,
7591 Some(TensorMemory::DmaBuf),
7592 CpuAccess::ReadWrite,
7593 )
7594 .unwrap();
7595 // For a texture tensor this is the backing pitch either way: the
7596 // allocation records the staging pitch when it exceeds the tight row,
7597 // and the two are equal when it does not.
7598 let stride = t.effective_row_stride().unwrap();
7599 let v = t.subview(stride * 2, &[2, 32, 4]).unwrap();
7600 assert!(
7601 v.plane_offset().is_some_and(|o| o > 0),
7602 "a sub-view at a row offset must carry one"
7603 );
7604 let m = v
7605 .map_read()
7606 .expect("a sub-view of a texture tensor must map");
7607 assert_eq!(m.as_slice().len(), 2 * stride, "two rows of the window");
7608 }
7609
7610 #[test]
7611 fn mem_subview_four_views_no_aliasing() {
7612 // One [4,3] f32 parent; four [1,3] views at 12-byte strides, each
7613 // written independently. Exercises a multi-byte element type (offsets
7614 // must stay element-aligned) and N-way zero-copy sharing.
7615 let parent = Tensor::<f32>::new(&[4, 3], Some(TensorMemory::Mem), None).unwrap();
7616 let frame = 3 * std::mem::size_of::<f32>();
7617 for i in 0..4 {
7618 let v = parent.subview(i * frame, &[1, 3]).unwrap();
7619 let val = i as f32 + 1.0;
7620 v.map()
7621 .unwrap()
7622 .as_mut_slice()
7623 .copy_from_slice(&[val, val, val]);
7624 }
7625 assert_eq!(
7626 parent.map().unwrap().as_slice(),
7627 &[1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0]
7628 );
7629 }
7630
7631 #[test]
7632 fn mem_subview_inherits_format_and_row_stride() {
7633 // A sub-view is a ready-to-use sub-image: it inherits the parent's
7634 // pixel format and (crucially) its padded row stride, so a strided
7635 // parent yields strided windows. Set a stride wider than the tight row
7636 // to exercise the row_stride inheritance path specifically.
7637 let mut parent = Tensor::<u8>::image(
7638 100,
7639 100,
7640 PixelFormat::Rgba,
7641 Some(TensorMemory::Mem),
7642 crate::CpuAccess::ReadWrite,
7643 )
7644 .unwrap();
7645 parent.set_row_stride_unchecked(512); // padded stride (> 100*4)
7646 let view = parent.subview(4096, &[10, 10, 4]).unwrap();
7647 assert_eq!(view.format(), Some(PixelFormat::Rgba), "format inherited");
7648 assert_eq!(view.row_stride(), Some(512), "row_stride inherited");
7649 }
7650
7651 #[test]
7652 fn mem_strided_subview_maps_offset_and_byte_size() {
7653 // Integration of the sub-region offset (PR #89) and the strided-map
7654 // `byte_size_override` (PR #90): a strided sub-view exposes its full
7655 // padded window (`row_stride × rows`) starting at the view's byte
7656 // offset, mapped zero-copy into the parent.
7657 let parent = Tensor::<u8>::new(&[2048], Some(TensorMemory::Mem), None).unwrap();
7658 let mut view = parent.subview(128, &[8, 16]).unwrap(); // 8 rows × 16 @ off 128
7659 assert_eq!(view.plane_offset(), Some(128));
7660 view.set_row_stride_unchecked(32); // padded stride (> 16)
7661
7662 {
7663 let mut m = view.map().unwrap();
7664 let s = m.as_mut_slice();
7665 // Strided map exposes the padded window: stride(32) × rows(8) = 256.
7666 assert_eq!(
7667 s.len(),
7668 256,
7669 "strided map exposes the full padded byte window"
7670 );
7671 s[0] = 0xAA; // row 0, col 0
7672 s[32] = 0xBB; // row 1, col 0 (one stride in)
7673 }
7674
7675 // Zero-copy: the writes land in the parent at the view's offset.
7676 let p = parent.map().unwrap();
7677 let pb = p.as_slice();
7678 assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
7679 assert_eq!(
7680 pb[128 + 32],
7681 0xBB,
7682 "row 1 writes at parent offset 128 + stride"
7683 );
7684 }
7685
7686 #[test]
7687 #[cfg(unix)]
7688 fn shm_subview_partitions_parent_buffer() {
7689 // Declares this test as an fd-opener; see FD_LOCK.
7690 let _lock = crate::tests::fd_lock_shared();
7691 // Mirrors `mem_subview_partitions_parent_buffer` for Shm: one [2,4] u8
7692 // parent shared segment (8 bytes); two [1,4] sub-views at byte offsets 0
7693 // and 4 must share the segment (zero-copy, via cloned fd) and be
7694 // independently writable — view 0 owns [0,4), view 1 owns [4,8).
7695 if !crate::is_shm_available() {
7696 crate::test_support::report_skip("shm not available");
7697 return;
7698 }
7699 let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Shm), None).unwrap();
7700 let view0 = parent.subview(0, &[1, 4]).expect("shm subview at offset 0");
7701 let view1 = parent.subview(4, &[1, 4]).expect("shm subview at offset 4");
7702
7703 view1
7704 .map()
7705 .unwrap()
7706 .as_mut_slice()
7707 .copy_from_slice(&[10, 20, 30, 40]);
7708 view0
7709 .map()
7710 .unwrap()
7711 .as_mut_slice()
7712 .copy_from_slice(&[1, 2, 3, 4]);
7713
7714 assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
7715 assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
7716 // The parent sees the full partitioned segment (shared, zero-copy).
7717 assert_eq!(
7718 parent.map().unwrap().as_slice(),
7719 &[1, 2, 3, 4, 10, 20, 30, 40]
7720 );
7721 // A sub-view of a sub-view composes the offset.
7722 let nested = view1.subview(2, &[1, 2]).expect("nested shm subview");
7723 assert_eq!(nested.map().unwrap().as_slice(), &[30, 40]);
7724 }
7725
7726 #[test]
7727 #[cfg(unix)]
7728 fn shm_subview_rejects_unaligned_and_oob() {
7729 // Declares this test as an fd-opener; see FD_LOCK.
7730 let _lock = crate::tests::fd_lock_shared();
7731 if !crate::is_shm_available() {
7732 crate::test_support::report_skip("shm not available");
7733 return;
7734 }
7735 // f32 align 4: a 2-byte offset cannot back a valid `*const f32`.
7736 let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
7737 assert!(parent.subview(2, &[1]).is_err());
7738 assert!(parent.subview(4, &[1]).is_ok());
7739 // Out of bounds: offset 6 + 4 bytes = 10 > 8-byte (u8) segment.
7740 let p2 = Tensor::<u8>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
7741 assert!(p2.subview(6, &[4]).is_err());
7742 }
7743
7744 #[test]
7745 #[cfg(target_os = "linux")]
7746 fn dma_subview_matches_mem_subview() {
7747 // Serialize against the fd-leak tests: this test opens DMA fds (alloc +
7748 // clone_fd), which would otherwise perturb their fd counts.
7749 let _lock = crate::tests::fd_lock_shared();
7750 // Identical sub-view semantics across Dma (shared fd) and Mem (shared
7751 // Arc): same offsets → same logical windows → same partition.
7752 let dma = match Tensor::<u8>::new(&[8], Some(TensorMemory::DmaBuf), None) {
7753 Ok(t) => t,
7754 Err(_) => {
7755 crate::test_support::report_skip("DMA not available");
7756 return;
7757 }
7758 };
7759 let mem = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7760 for parent in [&dma, &mem] {
7761 let v0 = parent.subview(0, &[4]).unwrap();
7762 let v1 = parent.subview(4, &[4]).unwrap();
7763 v0.map()
7764 .unwrap()
7765 .as_mut_slice()
7766 .copy_from_slice(&[1, 2, 3, 4]);
7767 v1.map()
7768 .unwrap()
7769 .as_mut_slice()
7770 .copy_from_slice(&[5, 6, 7, 8]);
7771 assert_eq!(parent.map().unwrap().as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]);
7772 }
7773 }
7774
7775 #[test]
7776 #[cfg(target_os = "linux")]
7777 fn dma_strided_subview_maps_padded_window() {
7778 // The strided-map path differs by backing: DMA maps through
7779 // `mmap_offset` + the `byte_size_override`, not the Mem `Arc` slice. A
7780 // padded sub-view of a DMA buffer must still expose its full
7781 // `row_stride × rows` window zero-copy at the view's offset (the GPU
7782 // batched-render-to-DMA case). Mirrors
7783 // `mem_strided_subview_maps_offset_and_byte_size` on a Dma parent.
7784 let _lock = crate::tests::fd_lock_shared();
7785 let parent = match Tensor::<u8>::new(&[2048], Some(TensorMemory::DmaBuf), None) {
7786 Ok(t) => t,
7787 Err(_) => {
7788 crate::test_support::report_skip("DMA not available");
7789 return;
7790 }
7791 };
7792 let mut view = parent.subview(128, &[8, 16]).unwrap();
7793 assert_eq!(view.plane_offset(), Some(128));
7794 view.set_row_stride_unchecked(32); // padded stride (> 16)
7795
7796 {
7797 let mut m = view.map().unwrap();
7798 let s = m.as_mut_slice();
7799 assert_eq!(s.len(), 256, "strided DMA map exposes stride(32) × rows(8)");
7800 s[0] = 0xAA; // row 0, col 0
7801 s[32] = 0xBB; // row 1, col 0 (one stride in)
7802 }
7803
7804 let p = parent.map().unwrap();
7805 let pb = p.as_slice();
7806 assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
7807 assert_eq!(
7808 pb[128 + 32],
7809 0xBB,
7810 "row 1 writes at parent offset 128 + stride"
7811 );
7812 }
7813
7814 #[test]
7815 #[cfg(target_os = "linux")]
7816 fn view_single_row_snapshots_parent_stride() {
7817 // A single-row `view()` keeps a TIGHT `row_stride` for map-span safety,
7818 // but its `view_origin` snapshots the PARENT row stride — the GL backend
7819 // keys its EGLImage import/pitch on that snapshot (not the view's tight
7820 // stride), so single-row and multi-row sibling views collapse onto the
7821 // same parent import.
7822 let _lock = crate::tests::fd_lock_shared();
7823 // 8x4 RGBA with a padded 64-byte row stride (tight row = 8*4 = 32).
7824 let parent = match Tensor::<u8>::image_with_stride(
7825 8,
7826 4,
7827 PixelFormat::Rgba,
7828 64,
7829 Some(TensorMemory::DmaBuf),
7830 crate::CpuAccess::ReadWrite,
7831 ) {
7832 Ok(t) => t,
7833 Err(_) => {
7834 crate::test_support::report_skip("DMA not available");
7835 return;
7836 }
7837 };
7838 assert_eq!(parent.effective_row_stride(), Some(64));
7839 // Bottom row (y=3) at x>0 — the case the tight single-row stride guards.
7840 let row = parent.view(Region::new(2, 3, 4, 1)).unwrap();
7841 // The view's own stride is tight (4*4 = 16) so its strided map stays in
7842 // bounds; the GL-facing parent pitch (64) lives in `view_origin`.
7843 assert_eq!(row.effective_row_stride(), Some(16));
7844 let vo = row.view_origin().expect("a view carries a view_origin");
7845 assert_eq!(
7846 vo.parent_row_stride, 64,
7847 "GL keys/pitches a view on the parent stride, not its tight one"
7848 );
7849 // The tight stride keeps map() in-bounds for the bottom / x>0 single row.
7850 assert_eq!(row.map().unwrap().as_slice().len(), 16);
7851 }
7852
7853 #[test]
7854 fn test_mem_tensor() {
7855 let shape = vec![2, 3, 4];
7856 let tensor =
7857 MemTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
7858 assert_eq!(tensor.shape(), &shape);
7859 assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
7860 assert_eq!(tensor.name(), "test_tensor");
7861
7862 {
7863 let mut tensor_map = tensor.map().expect("Failed to map memory");
7864 tensor_map.fill(42.0);
7865 assert!(tensor_map.iter().all(|&x| x == 42.0));
7866 }
7867
7868 let mut tensor = MemTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
7869 assert_eq!(tensor.shape(), &shape);
7870 let new_shape = vec![3, 4, 4];
7871 assert!(
7872 tensor.reshape(&new_shape).is_err(),
7873 "Reshape should fail due to size mismatch"
7874 );
7875 assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
7876
7877 let new_shape = vec![2, 3, 4];
7878 tensor.reshape(&new_shape).expect("Reshape should succeed");
7879 assert_eq!(
7880 tensor.shape(),
7881 &new_shape,
7882 "Shape should be updated after successful reshape"
7883 );
7884
7885 {
7886 let mut tensor_map = tensor.map().expect("Failed to map memory");
7887 tensor_map.fill(1);
7888 assert!(tensor_map.iter().all(|&x| x == 1));
7889 }
7890
7891 {
7892 let mut tensor_map = tensor.map().expect("Failed to map memory");
7893 tensor_map[2] = 42;
7894 assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
7895 assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
7896 }
7897 }
7898
7899 #[test]
7900 #[cfg(target_os = "linux")]
7901 fn test_dma_no_fd_leaks() {
7902 let _lock = fd_lock_exclusive();
7903 if !is_dma_available() {
7904 log::warn!(
7905 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7906 function!()
7907 );
7908 return;
7909 }
7910
7911 let baseline = open_fd_count(DMA_FD_TARGETS);
7912
7913 // Self-check — see `test_shm_no_fd_leaks`.
7914 {
7915 let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
7916 .expect("Failed to create tensor");
7917 assert!(
7918 open_fd_count(DMA_FD_TARGETS) > baseline,
7919 "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
7920 otherwise this test cannot detect a leak"
7921 );
7922 drop(probe);
7923 }
7924
7925 for _ in 0..100 {
7926 let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
7927 .expect("Failed to create tensor");
7928 let mut map = tensor.map().unwrap();
7929 map.as_mut_slice().fill(233);
7930 }
7931
7932 let after = open_fd_count(DMA_FD_TARGETS);
7933 assert_eq!(
7934 baseline, after,
7935 "DMA-BUF file descriptor leak detected: {baseline} -> {after}"
7936 );
7937 }
7938
7939 #[test]
7940 #[cfg(target_os = "linux")]
7941 fn test_dma_from_fd_no_fd_leaks() {
7942 let _lock = fd_lock_exclusive();
7943 if !is_dma_available() {
7944 log::warn!(
7945 "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7946 function!()
7947 );
7948 return;
7949 }
7950
7951 let baseline = open_fd_count(DMA_FD_TARGETS);
7952 let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None).unwrap();
7953
7954 // Self-check — see `test_shm_no_fd_leaks`.
7955 let held = open_fd_count(DMA_FD_TARGETS);
7956 assert!(
7957 held > baseline,
7958 "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
7959 otherwise this test cannot detect a leak"
7960 );
7961
7962 for _ in 0..100 {
7963 let tensor =
7964 Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
7965 assert_eq!(
7966 tensor.memory(),
7967 TensorMemory::DmaBuf,
7968 "DMA-BUF fd must import as Dma, not be silently downgraded"
7969 );
7970 let mut map = tensor.map().unwrap();
7971 map.as_mut_slice().fill(233);
7972 }
7973
7974 let after_loop = open_fd_count(DMA_FD_TARGETS);
7975 assert_eq!(
7976 held, after_loop,
7977 "DMA-BUF file descriptor leak detected: {held} -> {after_loop}"
7978 );
7979
7980 // And the source tensor's own fd goes back on drop.
7981 drop(orig);
7982 let after_drop = open_fd_count(DMA_FD_TARGETS);
7983 assert_eq!(
7984 baseline, after_drop,
7985 "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
7986 );
7987 }
7988
7989 /// A filesystem magic must report its true 32-bit value regardless of
7990 /// how wide, and how signed, `fstatfs`'s `f_type` is on the target.
7991 ///
7992 /// `fs_type_t` is `__fsword_t` on Linux/gnu — `i64` on 64-bit but `i32`
7993 /// on 32-bit (armv7, i686, aarch64-ilp32) — `c_int` on uclibc, and
7994 /// unsigned on musl and s390x. Widening a *signed* 32-bit `f_type`
7995 /// sign-extends every magic with bit 31 set, so a naive widening cast
7996 /// reports e.g. `0xffffffff958458f6` for hugetlbfs. The docs tell
7997 /// callers to look the reported value up in `include/uapi/linux/magic.h`,
7998 /// so a sign-extended value is actively misleading.
7999 ///
8000 /// This cannot be reproduced on a 64-bit host — the defect only exists
8001 /// where `f_type` is a signed 32-bit type — so the test drives the
8002 /// conversion directly with the value such a target would produce.
8003 #[test]
8004 #[cfg(target_os = "linux")]
8005 fn test_fs_magic_normalizes_sign_extended_values() {
8006 // Declares this test as an fd-opener; see FD_LOCK.
8007 let _lock = crate::tests::fd_lock_shared();
8008 // Magics whose bit 31 is set. HUGETLBFS is the one that matters
8009 // most in practice: a MFD_HUGETLB memfd is the likeliest fd to land
8010 // in the UnknownBufferType arm.
8011 const HUGETLBFS_MAGIC: u32 = 0x9584_58f6;
8012 const F2FS_MAGIC: u32 = 0xf2f5_2010;
8013 const BTRFS_MAGIC: u32 = 0x9123_683e;
8014
8015 for magic in [HUGETLBFS_MAGIC, F2FS_MAGIC, BTRFS_MAGIC] {
8016 // 64-bit gnu: f_type is i64 and already holds the true value.
8017 assert_eq!(fs_magic(i64::from(magic)), magic);
8018
8019 // 32-bit gnu / uclibc: f_type is i32, so the value arrives
8020 // sign-extended once widened. It must still report its true
8021 // 32 bits.
8022 let sign_extended = i64::from(magic as i32);
8023 assert!(sign_extended < 0, "{magic:#x} should have bit 31 set");
8024 assert_eq!(
8025 fs_magic(sign_extended),
8026 magic,
8027 "{magic:#x} must survive a signed 32-bit f_type"
8028 );
8029 }
8030
8031 // The two magics we actually classify on are below 2^31, so they are
8032 // unaffected by signedness either way — this is why the DMA-vs-SHM
8033 // decision is correct on 32-bit even without this normalization.
8034 for magic in [DMA_BUF_MAGIC, TMPFS_MAGIC] {
8035 assert!(magic < 0x8000_0000);
8036 assert_eq!(fs_magic(i64::from(magic as i32)), magic);
8037 }
8038 }
8039
8040 /// A DMA-BUF fd must import as [`TensorMemory::DmaBuf`].
8041 ///
8042 /// Regression test for the `st_dev` minor-number classifier, which
8043 /// hardcoded `9 | 10` as "this is DMA". Those minors come from
8044 /// `get_anon_bdev()` and are assigned first-come-first-served at boot,
8045 /// so they vary by kernel build and boot order — a real DMA-BUF is
8046 /// minor 12 on x86 desktop and minor 8 on the ADIS Verdin. Both fell
8047 /// into the `_` arm and imported as SHM, which "works" (a DMA-BUF is
8048 /// mmap-able) but silently forfeits zero-copy.
8049 #[test]
8050 #[cfg(target_os = "linux")]
8051 fn test_from_fd_dma_imports_as_dma() {
8052 let _lock = crate::tests::fd_lock_shared();
8053 if !is_dma_available() {
8054 log::warn!("SKIPPED: {} - DMA memory not available", function!());
8055 return;
8056 }
8057
8058 let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::DmaBuf), None).unwrap();
8059 assert_eq!(orig.memory(), TensorMemory::DmaBuf);
8060
8061 let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
8062
8063 assert_eq!(
8064 imported.memory(),
8065 TensorMemory::DmaBuf,
8066 "a DMA-BUF fd must import as Dma"
8067 );
8068 }
8069
8070 /// A tmpfs/SHM fd must import as [`TensorMemory::Shm`].
8071 ///
8072 /// The companion to `test_from_fd_dma_imports_as_dma`: confirms the
8073 /// magic-based classifier identifies SHM positively rather than by
8074 /// falling through.
8075 #[test]
8076 #[cfg(target_os = "linux")]
8077 fn test_from_fd_shm_imports_as_shm() {
8078 let _lock = crate::tests::fd_lock_shared();
8079 if !is_shm_available() {
8080 log::warn!("SKIPPED: {} - SHM memory not available", function!());
8081 return;
8082 }
8083
8084 let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::Shm), None).unwrap();
8085 assert_eq!(orig.memory(), TensorMemory::Shm);
8086
8087 let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
8088
8089 assert_eq!(
8090 imported.memory(),
8091 TensorMemory::Shm,
8092 "a tmpfs fd must import as Shm"
8093 );
8094 }
8095
8096 /// An fd that is neither a DMA-BUF nor tmpfs must be rejected.
8097 ///
8098 /// A pipe is the convenient probe: it lives on `pipefs`, another
8099 /// `get_anon_bdev()` pseudo-filesystem, so it shares major 0 with the
8100 /// buffer types we do support and is only distinguishable by magic.
8101 /// Importing one as SHM is meaningless — `mmap` on a pipe fails — so
8102 /// the classifier must say "unknown" rather than guess.
8103 #[test]
8104 #[cfg(target_os = "linux")]
8105 fn test_from_fd_rejects_unknown_filesystem() {
8106 let _lock = crate::tests::fd_lock_shared();
8107
8108 let (read_end, _write_end) = nix::unistd::pipe().unwrap();
8109
8110 let result = Tensor::<u8>::from_fd(read_end, &[64], None);
8111
8112 match result {
8113 Err(Error::UnknownBufferType(magic)) => {
8114 // PIPEFS_MAGIC, from include/uapi/linux/magic.h
8115 assert_eq!(magic, 0x5049_5045, "expected PIPEFS_MAGIC");
8116 }
8117 other => panic!("expected UnknownBufferType for a pipe fd, got {other:?}"),
8118 }
8119 }
8120
8121 #[test]
8122 #[cfg(target_os = "linux")]
8123 fn test_shm_no_fd_leaks() {
8124 let _lock = fd_lock_exclusive();
8125 if !is_shm_available() {
8126 log::warn!(
8127 "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
8128 function!()
8129 );
8130 return;
8131 }
8132
8133 let baseline = open_fd_count(SHM_FD_TARGETS);
8134
8135 // Self-check: the counter must actually see a live shm tensor. If
8136 // it does not — a changed backing object, a different `/proc`
8137 // rendering — the leak assertion below would hold no matter how
8138 // much leaked, so fail loudly here instead.
8139 {
8140 let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
8141 .expect("Failed to create tensor");
8142 assert!(
8143 open_fd_count(SHM_FD_TARGETS) > baseline,
8144 "a live shm tensor must be visible to the shm fd counter, \
8145 otherwise this test cannot detect a leak"
8146 );
8147 drop(probe);
8148 }
8149
8150 for _ in 0..100 {
8151 let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
8152 .expect("Failed to create tensor");
8153 let mut map = tensor.map().unwrap();
8154 map.as_mut_slice().fill(233);
8155 }
8156
8157 let after = open_fd_count(SHM_FD_TARGETS);
8158 assert_eq!(
8159 baseline, after,
8160 "shm file descriptor leak detected: {baseline} -> {after}"
8161 );
8162 }
8163
8164 #[test]
8165 #[cfg(target_os = "linux")]
8166 fn test_shm_from_fd_no_fd_leaks() {
8167 let _lock = fd_lock_exclusive();
8168 if !is_shm_available() {
8169 log::warn!(
8170 "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
8171 function!()
8172 );
8173 return;
8174 }
8175
8176 let baseline = open_fd_count(SHM_FD_TARGETS);
8177 let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None).unwrap();
8178
8179 // Self-check — see `test_shm_no_fd_leaks`.
8180 let held = open_fd_count(SHM_FD_TARGETS);
8181 assert!(
8182 held > baseline,
8183 "a live shm tensor must be visible to the shm fd counter, \
8184 otherwise this test cannot detect a leak"
8185 );
8186
8187 for _ in 0..100 {
8188 let tensor =
8189 Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
8190 let mut map = tensor.map().unwrap();
8191 map.as_mut_slice().fill(233);
8192 }
8193
8194 let after_loop = open_fd_count(SHM_FD_TARGETS);
8195 assert_eq!(
8196 held, after_loop,
8197 "shm file descriptor leak detected: {held} -> {after_loop}"
8198 );
8199
8200 // And the source tensor's own fd goes back on drop.
8201 drop(orig);
8202 let after_drop = open_fd_count(SHM_FD_TARGETS);
8203 assert_eq!(
8204 baseline, after_drop,
8205 "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
8206 );
8207 }
8208
8209 #[cfg(feature = "ndarray")]
8210 #[test]
8211 fn test_ndarray() {
8212 let _lock = crate::tests::fd_lock_shared();
8213 let shape = vec![2, 3, 4];
8214 let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8215
8216 let mut tensor_map = tensor.map().expect("Failed to map tensor memory");
8217 tensor_map.fill(1.0);
8218
8219 let view = tensor_map.view().expect("Failed to get ndarray view");
8220 assert_eq!(view.shape(), &[2, 3, 4]);
8221 assert!(view.iter().all(|&x| x == 1.0));
8222
8223 let mut view_mut = tensor_map
8224 .view_mut()
8225 .expect("Failed to get mutable ndarray view");
8226 view_mut[[0, 0, 0]] = 42.0;
8227 assert_eq!(view_mut[[0, 0, 0]], 42.0);
8228 assert_eq!(tensor_map[0], 42.0, "Value at index 0 should be 42");
8229 }
8230
8231 #[test]
8232 fn test_buffer_identity_derived_equality() {
8233 // Two identities derived from the same kind and key must be equal —
8234 // that is the whole point of `derived`: two independently-linked
8235 // copies of this crate, each importing the same underlying buffer,
8236 // have to agree on its id without talking to each other.
8237 let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
8238 let id2 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
8239 assert_eq!(
8240 id1.id(),
8241 id2.id(),
8242 "Identities derived from the same kind and key should match"
8243 );
8244
8245 // A different key still yields a different id.
8246 let id3 = BufferIdentity::derived(IdentityKind::DmaBuf, 4243);
8247 assert_ne!(
8248 id1.id(),
8249 id3.id(),
8250 "Identities derived from different keys should differ"
8251 );
8252 }
8253
8254 #[test]
8255 fn test_buffer_identity_clone_shares_guard() {
8256 let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
8257 let weak = id1.weak();
8258 assert!(
8259 weak.upgrade().is_some(),
8260 "Weak should be alive while original exists"
8261 );
8262
8263 let id2 = id1.clone();
8264 assert_eq!(id1.id(), id2.id(), "Cloned identity should have same id");
8265
8266 drop(id1);
8267 assert!(
8268 weak.upgrade().is_some(),
8269 "Weak should still be alive (clone holds Arc)"
8270 );
8271
8272 drop(id2);
8273 assert!(
8274 weak.upgrade().is_none(),
8275 "Weak should be dead after all clones dropped"
8276 );
8277 }
8278
8279 #[test]
8280 fn test_tensor_buffer_identity() {
8281 let t1 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t1")).unwrap();
8282 let t2 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t2")).unwrap();
8283 assert_ne!(
8284 t1.buffer_identity().id(),
8285 t2.buffer_identity().id(),
8286 "Different tensors should have different buffer ids"
8287 );
8288 }
8289
8290 // ------------------------------------------------------------------------
8291 // Quantization — constructor validation + accessor correctness.
8292 // ------------------------------------------------------------------------
8293
8294 #[test]
8295 fn test_quantization_per_tensor_constructors() {
8296 let q = Quantization::per_tensor(0.1, -5);
8297 assert!(q.is_per_tensor());
8298 assert!(!q.is_per_channel());
8299 assert!(!q.is_symmetric());
8300 assert_eq!(q.scale(), &[0.1]);
8301 assert_eq!(q.zero_point(), Some(&[-5][..]));
8302
8303 let qs = Quantization::per_tensor_symmetric(0.05);
8304 assert!(qs.is_per_tensor());
8305 assert!(qs.is_symmetric());
8306 assert_eq!(qs.zero_point(), None);
8307 }
8308
8309 #[test]
8310 fn test_quantization_per_channel_constructors() {
8311 let q = Quantization::per_channel(vec![0.1, 0.2, 0.3], vec![0, -1, 1], 2).unwrap();
8312 assert!(q.is_per_channel());
8313 assert!(!q.is_symmetric());
8314 assert_eq!(q.axis(), Some(2));
8315 assert_eq!(q.scale().len(), 3);
8316
8317 let qs = Quantization::per_channel_symmetric(vec![0.054, 0.089, 0.195], 0).unwrap();
8318 assert!(qs.is_per_channel());
8319 assert!(qs.is_symmetric());
8320 assert_eq!(qs.axis(), Some(0));
8321 }
8322
8323 #[test]
8324 fn test_quantization_per_channel_length_mismatch_rejected() {
8325 // len(scales) != len(zero_points) → rejected at construction.
8326 let err = Quantization::per_channel(vec![0.1, 0.2], vec![0, 0, 0], 0).unwrap_err();
8327 assert!(matches!(err, Error::QuantizationInvalid { .. }));
8328 }
8329
8330 #[test]
8331 fn test_quantization_per_channel_empty_rejected() {
8332 let err = Quantization::per_channel_symmetric(vec![], 0).unwrap_err();
8333 assert!(matches!(err, Error::QuantizationInvalid { .. }));
8334 }
8335
8336 /// Constructors guard scale/zero_point length invariants, but
8337 /// `Quantization` is `Deserialize`, so malformed JSON (e.g. an
8338 /// empty `scale` array, or `zero_point` length that disagrees with
8339 /// `scale`) bypasses the constructor checks. `set_quantization`
8340 /// must reject these via `validate()` so they don't poison
8341 /// downstream `mode()` selection or per-channel kernel indexing.
8342 #[test]
8343 fn test_quantization_validate_rejects_malformed_deserialize() {
8344 let mut t = Tensor::<i8>::new(&[1, 1, 4], Some(TensorMemory::Mem), None).unwrap();
8345
8346 // Empty scale array: must be rejected.
8347 let q: Quantization = serde_json::from_str(r#"{"scale": []}"#).unwrap();
8348 assert!(matches!(
8349 t.set_quantization(q).unwrap_err(),
8350 Error::QuantizationInvalid { .. }
8351 ));
8352
8353 // Per-tensor with multi-element zero_point: must be rejected.
8354 let q: Quantization =
8355 serde_json::from_str(r#"{"scale": 0.1, "zero_point": [0, 0, 0]}"#).unwrap();
8356 assert!(matches!(
8357 t.set_quantization(q).unwrap_err(),
8358 Error::QuantizationInvalid { .. }
8359 ));
8360
8361 // Per-channel zero_point length != scale length: must be rejected.
8362 let q: Quantization = serde_json::from_str(
8363 r#"{"scale": [0.1, 0.2, 0.3, 0.4], "zero_point": [0, 0], "axis": 2}"#,
8364 )
8365 .unwrap();
8366 assert!(matches!(
8367 t.set_quantization(q).unwrap_err(),
8368 Error::QuantizationInvalid { .. }
8369 ));
8370 }
8371
8372 #[test]
8373 fn test_quantization_mode_dispatch() {
8374 let pt = Quantization::per_tensor(0.1, -5);
8375 assert!(matches!(
8376 pt.mode(),
8377 QuantMode::PerTensor { scale, zero_point } if scale == 0.1 && zero_point == -5
8378 ));
8379
8380 let pts = Quantization::per_tensor_symmetric(0.05);
8381 assert!(matches!(
8382 pts.mode(),
8383 QuantMode::PerTensorSymmetric { scale } if scale == 0.05
8384 ));
8385
8386 let pc = Quantization::per_channel(vec![0.1, 0.2], vec![0, -1], 2).unwrap();
8387 assert!(matches!(pc.mode(), QuantMode::PerChannel { axis: 2, .. }));
8388
8389 let pcs = Quantization::per_channel_symmetric(vec![0.1, 0.2], 0).unwrap();
8390 assert!(matches!(
8391 pcs.mode(),
8392 QuantMode::PerChannelSymmetric { axis: 0, .. }
8393 ));
8394 }
8395
8396 #[test]
8397 fn test_tensor_quantization_roundtrip_integer() {
8398 let mut t = Tensor::<i8>::new(&[2, 3, 4], Some(TensorMemory::Mem), None).unwrap();
8399 assert!(t.quantization().is_none());
8400 t.set_quantization(Quantization::per_tensor(0.1, -5))
8401 .unwrap();
8402 let q = t.quantization().unwrap();
8403 assert_eq!(q.scale(), &[0.1]);
8404 t.clear_quantization();
8405 assert!(t.quantization().is_none());
8406 }
8407
8408 #[test]
8409 fn test_tensor_with_quantization_builder() {
8410 let t = Tensor::<i8>::new(&[4, 4], Some(TensorMemory::Mem), None)
8411 .unwrap()
8412 .with_quantization(Quantization::per_tensor_symmetric(0.05))
8413 .unwrap();
8414 assert!(t.quantization().is_some());
8415 }
8416
8417 #[test]
8418 fn test_tensor_dyn_quantization_float_arm_returns_none() {
8419 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8420 let td = TensorDyn::F32(t);
8421 assert!(td.quantization().is_none());
8422 }
8423
8424 #[test]
8425 fn test_tensor_dyn_set_quantization_float_arm_errors() {
8426 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8427 let mut td = TensorDyn::F32(t);
8428 let err = td
8429 .set_quantization(Quantization::per_tensor(0.1, 0))
8430 .unwrap_err();
8431 // float path returns a QuantizationInvalid error.
8432 assert!(matches!(err, Error::QuantizationInvalid { .. }));
8433 }
8434
8435 /// Compile-time type gate — calling `Tensor::<f32>::quantization()` must
8436 /// fail to compile (the `IntegerType` trait bound is not satisfied by
8437 /// `f32`). This doctest anchors the invariant.
8438 ///
8439 /// ```compile_fail
8440 /// use edgefirst_tensor::{Tensor, TensorMemory};
8441 /// let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8442 /// let _ = t.quantization(); // compile error: f32 not IntegerType
8443 /// ```
8444 fn _compile_fail_doctest_anchor() {}
8445
8446 // Any test that cares about the fd count must grab it exclusively.
8447 // Any tests which modifies the fd count by opening or closing fds must grab it
8448 // shared.
8449 pub static FD_LOCK: RwLock<()> = RwLock::new(());
8450
8451 /// Take [`FD_LOCK`] shared, tolerating poisoning.
8452 ///
8453 /// A test that panics while holding the lock poisons it, and every later
8454 /// `.unwrap()` on it panics too — one real failure turns into a handful
8455 /// of unrelated ones that bury it. The guarded data is `()`, so a panic
8456 /// cannot have left an invariant broken and there is nothing to protect
8457 /// against by refusing the lock.
8458 pub fn fd_lock_shared() -> std::sync::RwLockReadGuard<'static, ()> {
8459 FD_LOCK.read().unwrap_or_else(PoisonError::into_inner)
8460 }
8461
8462 /// Take [`FD_LOCK`] exclusively. Poison-tolerant, see [`fd_lock_shared`].
8463 ///
8464 /// Linux-only, like all four of its callers: the fd-leak tests read fd
8465 /// state out of `/proc`, which no other platform provides. Ungated, it
8466 /// is dead code on macOS and the lint denies that.
8467 /// [`fd_lock_shared`] stays available everywhere — macOS tests declare
8468 /// fd activity too.
8469 #[cfg(target_os = "linux")]
8470 pub fn fd_lock_exclusive() -> std::sync::RwLockWriteGuard<'static, ()> {
8471 FD_LOCK.write().unwrap_or_else(PoisonError::into_inner)
8472 }
8473
8474 /// `/proc` fd-target fragments identifying a POSIX shared memory
8475 /// segment. `shm_open` then `shm_unlink` leaves the target rendered as
8476 /// `/dev/shm/<name> (deleted)`.
8477 #[cfg(target_os = "linux")]
8478 const SHM_FD_TARGETS: &[&str] = &["/dev/shm/"];
8479
8480 /// `/proc` fd-target fragments identifying the fds a DMA tensor owns.
8481 ///
8482 /// Both the exported buffer and the heap it came from are listed, so a
8483 /// leak of either is still caught — narrowing this to the buffer alone
8484 /// would silently stop covering leaked heap handles.
8485 #[cfg(target_os = "linux")]
8486 const DMA_FD_TARGETS: &[&str] = &["dmabuf", "dma_heap"];
8487
8488 /// How many of this process's open fds point at an object whose `/proc`
8489 /// target contains `needle`.
8490 ///
8491 /// The fd-leak tests deliberately do NOT compare total fd counts. Under
8492 /// `cargo test` every test in this crate runs as a thread in one
8493 /// process, and roughly twenty of them create shm or DMA tensors —
8494 /// while anything else may open a file, a socket, or a pipe — so an
8495 /// unrelated test opening or closing an fd inside the measurement
8496 /// window moved the total and failed the assertion. Worse, the panic
8497 /// poisoned [`FD_LOCK`] and took four more tests down with it, so the
8498 /// visible failure had little to do with the cause.
8499 ///
8500 /// Counting only fds that name the object under test makes the
8501 /// measurement immune to every unrelated fd, while still catching the
8502 /// leak each test exists to catch. (CI runs nextest, one process per
8503 /// test, which is why this only ever flaked when run locally.)
8504 #[cfg(target_os = "linux")]
8505 fn open_fd_count(needles: &[&str]) -> usize {
8506 let proc = procfs::process::Process::myself()
8507 .expect("Failed to get current process using /proc/self");
8508 proc.fd()
8509 .expect("Failed to list open file descriptors")
8510 .filter_map(Result::ok)
8511 .filter(|entry| {
8512 let target = format!("{:?}", entry.target);
8513 needles.iter().any(|needle| target.contains(needle))
8514 })
8515 .count()
8516 }
8517
8518 /// Test that DMA is NOT available on non-Linux platforms.
8519 /// This verifies the cross-platform behavior of is_dma_available().
8520 #[test]
8521 #[cfg(not(target_os = "linux"))]
8522 fn test_dma_not_available_on_non_linux() {
8523 assert!(
8524 !is_dma_available(),
8525 "DMA memory allocation should NOT be available on non-Linux platforms"
8526 );
8527 }
8528
8529 #[test]
8530 fn colorimetry_defaults_none_and_roundtrips_without_auto_fill() {
8531 use crate::{ColorEncoding, ColorRange, Colorimetry, PixelFormat, TensorMemory};
8532 let mut t = Tensor::<u8>::image(
8533 1280,
8534 720,
8535 PixelFormat::Nv12,
8536 Some(TensorMemory::Mem),
8537 crate::CpuAccess::ReadWrite,
8538 )
8539 .unwrap();
8540 assert_eq!(t.colorimetry(), None); // default undefined
8541 let c = Colorimetry::default()
8542 .with_encoding(ColorEncoding::Bt709)
8543 .with_range(ColorRange::Limited);
8544 t.set_colorimetry(Some(c));
8545 assert_eq!(t.colorimetry(), Some(c));
8546 // configure_image must NOT touch colorimetry
8547 t.configure_image(640, 480, PixelFormat::Grey).unwrap();
8548 assert_eq!(t.colorimetry(), Some(c));
8549 }
8550
8551 #[test]
8552 fn configure_image_within_capacity() {
8553 let mut t = Tensor::<u8>::image_with_capacity(
8554 640,
8555 480,
8556 PixelFormat::Rgb,
8557 None,
8558 crate::CpuAccess::ReadWrite,
8559 )
8560 .unwrap();
8561 t.configure_image(320, 240, PixelFormat::Nv12).unwrap();
8562 assert_eq!(t.format(), Some(PixelFormat::Nv12));
8563 assert_eq!(t.width(), Some(320));
8564 assert_eq!(t.height(), Some(240));
8565 assert_eq!(t.shape(), &[360, 320]); // 240*3/2
8566 }
8567
8568 #[test]
8569 fn configure_image_too_large_errors() {
8570 let mut t = Tensor::<u8>::image_with_capacity(
8571 64,
8572 64,
8573 PixelFormat::Grey,
8574 None,
8575 crate::CpuAccess::ReadWrite,
8576 )
8577 .unwrap();
8578 let err = t
8579 .configure_image(1920, 1080, PixelFormat::Nv12)
8580 .unwrap_err();
8581 assert!(matches!(err, Error::InsufficientCapacity { .. }));
8582 }
8583
8584 /// A reused max-sized IOSurface pool keeps its physical `bytesPerRow` when
8585 /// reconfigured to a smaller logical image (physical-grid / logical-ROI
8586 /// decoupling), instead of collapsing to the frame's natural row stride.
8587 #[test]
8588 #[cfg(target_os = "macos")]
8589 fn configure_image_preserves_iosurface_physical_stride() {
8590 // Declares this test as an fd-opener; see FD_LOCK.
8591 let _lock = crate::tests::fd_lock_shared();
8592 // Pool: GREY/R8 IOSurface 100 wide → bytesPerRow padded to 128.
8593 let mut pool = Tensor::<u8>::image(
8594 100,
8595 64,
8596 PixelFormat::Grey,
8597 Some(TensorMemory::DmaBuf),
8598 crate::CpuAccess::ReadWrite,
8599 )
8600 .unwrap();
8601 let pitch = pool.effective_row_stride().unwrap();
8602 assert!(
8603 pitch >= 128 && pitch.is_multiple_of(64),
8604 "padded bytesPerRow, got {pitch}"
8605 );
8606
8607 // Reconfigure to a smaller NV12 frame; the physical pitch must survive
8608 // (natural would be 32, but the surface stride is the 128-padded pitch).
8609 pool.configure_image(32, 16, PixelFormat::Nv12).unwrap();
8610 assert_eq!(pool.format(), Some(PixelFormat::Nv12));
8611 assert_eq!(pool.width(), Some(32));
8612 assert_eq!(pool.height(), Some(16));
8613 assert_eq!(
8614 pool.effective_row_stride(),
8615 Some(pitch),
8616 "configure_image must preserve the IOSurface physical bytesPerRow"
8617 );
8618
8619 // Reconfigure again to NV24 — pitch still preserved.
8620 pool.configure_image(32, 16, PixelFormat::Nv24).unwrap();
8621 assert_eq!(pool.effective_row_stride(), Some(pitch));
8622 }
8623
8624 /// `configure_image` on a Mem backing reconfigures to the format's
8625 /// **64-byte-aligned** row stride (the odd-dim contract: every image tensor
8626 /// carries a 64-aligned `row_stride`). For NV12 32×16 the minimum is
8627 /// `even(32)=32`, rounded up to the 64-byte alignment → 64. The capacity
8628 /// (64×64×4 RGBA = 16 KiB) easily holds the 24×64 = 1.5 KiB NV12 layout.
8629 #[test]
8630 fn configure_image_mem_aligns_stride() {
8631 let mut t = Tensor::<u8>::image_with_capacity(
8632 64,
8633 64,
8634 PixelFormat::Rgba,
8635 Some(TensorMemory::Mem),
8636 crate::CpuAccess::ReadWrite,
8637 )
8638 .unwrap();
8639 t.configure_image(32, 16, PixelFormat::Nv12).unwrap();
8640 let s = t.effective_row_stride().unwrap();
8641 assert_eq!(s % 64, 0, "stride must be 64-aligned");
8642 assert!(s >= 32, "stride must cover the even-width minimum");
8643 assert_eq!(s, 64);
8644 }
8645
8646 #[test]
8647 fn strided_mem_tensor_cpu_maps_full_padded_buffer() {
8648 // A packed RGBA image with row padding (GPU-pitch style): logical width
8649 // 8 px (32 B/row) but a 48-byte row stride. Over-allocate capacity (for
8650 // 16 px), narrow the logical width, then record the padded stride.
8651 // Previously `map()` rejected this on non-Linux with
8652 // "DMA backing is Linux-only"; HAL-owned Mem is now mappable.
8653 let mut t = Tensor::<u8>::image_with_capacity(
8654 16,
8655 3,
8656 PixelFormat::Rgba,
8657 Some(TensorMemory::Mem),
8658 crate::CpuAccess::ReadWrite,
8659 )
8660 .unwrap(); // capacity 3 × 16 × 4 = 192 B
8661 t.configure_image(8, 3, PixelFormat::Rgba).unwrap(); // logical [3, 8, 4] = 96 B
8662 t.set_row_stride(48).unwrap(); // padded stride (>= 32 B min)
8663
8664 let map = t.map().expect("strided Mem tensor should CPU-map");
8665 // Full padded buffer (stride 48 × 3 rows = 144 B), not the 96 B logical
8666 // view — callers iterate rows via `effective_row_stride()`.
8667 assert_eq!(map.as_slice().len(), 144);
8668 // Logical shape is still reported for shape-aware consumers.
8669 assert_eq!(map.shape(), &[3, 8, 4]);
8670 }
8671
8672 #[test]
8673 fn strided_mem_tensor_over_capacity_errors() {
8674 // Stride larger than the allocation: 64 B × 3 rows = 192 B > 96 B cap.
8675 let mut t = Tensor::<u8>::new(&[3, 8, 4], Some(TensorMemory::Mem), None).unwrap();
8676 t.set_format(PixelFormat::Rgba).unwrap();
8677 t.set_row_stride(64).unwrap();
8678 assert!(matches!(t.map(), Err(Error::InsufficientCapacity { .. })));
8679 }
8680
8681 /// Test that SHM memory allocation is available and usable on Unix systems.
8682 /// This is a basic functional test; Linux has additional FD leak tests using procfs.
8683 #[test]
8684 #[cfg(unix)]
8685 fn test_shm_available_and_usable() {
8686 // Declares this test as an fd-opener; see FD_LOCK.
8687 let _lock = crate::tests::fd_lock_shared();
8688 // "Unix" is not one platform here: Android is unix but its bionic libc
8689 // has no POSIX shared memory, so allocation legitimately reports
8690 // NotImplemented (see shm.rs module docs). Asserting availability
8691 // outright fails there for a documented reason.
8692 if !is_shm_available() {
8693 log::warn!(
8694 "SKIPPED: test_shm_available_and_usable - SHM allocation \
8695 unavailable on this platform"
8696 );
8697 return;
8698 }
8699
8700 // Create a tensor with SHM backing
8701 let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
8702 .expect("Failed to create SHM tensor");
8703
8704 // Verify we can map and write to it
8705 let mut map = tensor.map().expect("Failed to map SHM tensor");
8706 map.as_mut_slice().fill(0xAB);
8707
8708 // Verify the data was written correctly
8709 assert!(
8710 map.as_slice().iter().all(|&b| b == 0xAB),
8711 "SHM tensor data should be writable and readable"
8712 );
8713 }
8714
8715 // =========================================================================
8716 // packed_rgba16f_layout — host-runnable geometry unit tests (TDD)
8717 // =========================================================================
8718
8719 #[test]
8720 fn packed_rgba16f_layout_planar_rgb_f16() {
8721 let layout =
8722 packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 640).expect("Some");
8723 assert_eq!(layout.surface_w, 160);
8724 assert_eq!(layout.surface_h, 1920);
8725 assert_eq!(layout.bytes_per_texel, 8);
8726 assert_eq!(layout.pitch, 1280);
8727 }
8728
8729 #[test]
8730 fn packed_rgba16f_layout_planar_rgba_f16() {
8731 let layout =
8732 packed_rgba16f_layout(PixelFormat::PlanarRgba, DType::F16, 640, 640).expect("Some");
8733 assert_eq!(layout.surface_w, 160);
8734 assert_eq!(layout.surface_h, 2560); // 4 planes
8735 assert_eq!(layout.bytes_per_texel, 8);
8736 assert_eq!(layout.pitch, 1280);
8737 }
8738
8739 #[test]
8740 fn packed_rgba16f_layout_rejects_misaligned() {
8741 assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 642, 640).is_none());
8742 }
8743
8744 #[test]
8745 fn packed_rgba16f_layout_rejects_non_f16() {
8746 // Non-F16 dtype with planar RGB
8747 assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::U8, 640, 640).is_none());
8748 // Non-planar format with F32
8749 assert!(packed_rgba16f_layout(PixelFormat::Rgb, DType::F32, 640, 640).is_none());
8750 // Packed Rgba with F16 is not a planar format → None
8751 assert!(packed_rgba16f_layout(PixelFormat::Rgba, DType::F16, 640, 640).is_none());
8752 }
8753
8754 #[test]
8755 fn cuda_map_fast_fails_to_none_without_handle() {
8756 let t = Tensor::<f32>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
8757 assert!(t.cuda().is_none());
8758 assert!(t.cuda_map().is_none()); // pure local check, no GL routing
8759 }
8760
8761 #[test]
8762 fn cuda_returns_none_without_handle() {
8763 // A plain Mem-backed tensor has no CUDA handle attached.
8764 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8765 assert!(t.cuda().is_none(), "no CUDA handle on a Mem tensor");
8766 assert!(t.cuda_map().is_none(), "fast-fail map → None");
8767 }
8768
8769 #[test]
8770 fn cuda_map_then_host_map_fallback() {
8771 // The documented client pattern: try cuda_map() first; when it is None
8772 // (no CUDA handle — the case for a plain Mem tensor), fall back to map().
8773 let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8774 // Bind to a named variable so the CudaMap guard (and its borrow of `t`)
8775 // is dropped at the end of this statement, before the else branch borrows `t` again.
8776 let cuda = t.cuda_map();
8777 if let Some(_c) = cuda {
8778 // On a CUDA-registered tensor we'd use the device ptr here.
8779 unreachable!("a Mem tensor has no CUDA handle");
8780 } else {
8781 let host = t.map().expect("host map fallback must succeed");
8782 // TensorMapTrait::len() returns the element count (not bytes).
8783 assert_eq!(host.len(), 4); // 2*2 f32 elements
8784 }
8785 }
8786
8787 // -------------------------------------------------------------------------
8788 // Tensor::from_foreign — public API tests at the Tensor<T> layer.
8789 //
8790 // The low-level MemTensor::from_foreign mechanics (owner-drop, view sharing)
8791 // are covered in mem.rs. These tests exercise the Tensor<T> guard paths
8792 // (null ptr, empty shape, size overflow) and the basic wrap+readback
8793 // contract, confirming the public unsafe API wires through correctly.
8794 // -------------------------------------------------------------------------
8795
8796 #[test]
8797 fn from_foreign_valid_wrap_and_readback() {
8798 // The canonical CUDA zero-copy shape: wrap a caller allocation as a
8799 // Mem tensor and verify the tensor reads the exact same bytes.
8800 let mut buf: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
8801 let ptr = buf.as_mut_ptr();
8802 let t = unsafe { Tensor::<f32>::from_foreign(ptr, &[2, 3], None, Some("test_foreign")) }
8803 .expect("valid from_foreign must succeed");
8804 assert_eq!(t.shape(), &[2, 3]);
8805 assert_eq!(t.memory(), TensorMemory::Mem);
8806 assert_eq!(t.name(), "test_foreign");
8807 let m = t.map().unwrap();
8808 // The tensor is a zero-copy borrow — it sees the caller's data.
8809 assert_eq!(m.as_slice(), &[1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]);
8810 }
8811
8812 #[test]
8813 fn from_foreign_write_visible_in_caller_allocation() {
8814 // Writes through the tensor's map land in the caller's buffer (zero-copy).
8815 let mut buf: Vec<u8> = vec![0u8; 6];
8816 let ptr = buf.as_mut_ptr();
8817 let t = unsafe { Tensor::<u8>::from_foreign(ptr, &[2, 3], None, None) }.unwrap();
8818 {
8819 let mut m = t.map().unwrap();
8820 m.as_mut_slice().copy_from_slice(&[10, 20, 30, 40, 50, 60]);
8821 }
8822 drop(t);
8823 // Mutations are visible in the original Vec — same physical buffer.
8824 assert_eq!(buf, vec![10, 20, 30, 40, 50, 60]);
8825 }
8826
8827 #[test]
8828 fn from_foreign_rejects_null_ptr() {
8829 let err = unsafe { Tensor::<u8>::from_foreign(std::ptr::null_mut(), &[4], None, None) }
8830 .unwrap_err();
8831 assert!(
8832 matches!(err, Error::InvalidArgument(ref m) if m.contains("non-null")),
8833 "expected InvalidArgument(non-null), got {err:?}"
8834 );
8835 }
8836
8837 #[test]
8838 fn from_foreign_rejects_empty_shape() {
8839 let mut dummy: u8 = 0;
8840 let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &[], None, None) }.unwrap_err();
8841 assert!(
8842 matches!(err, Error::InvalidSize(0)),
8843 "expected InvalidSize(0) for empty shape, got {err:?}"
8844 );
8845 }
8846
8847 #[test]
8848 fn from_foreign_rejects_overflow_shape() {
8849 // Two dimensions whose product overflows usize — the overflow guard must
8850 // fire before any pointer arithmetic is attempted.
8851 let mut dummy: u8 = 0;
8852 let huge = [usize::MAX / 2 + 1, 2];
8853 let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &huge, None, None) }.unwrap_err();
8854 assert!(
8855 matches!(err, Error::InvalidArgument(ref m) if m.contains("overflow")),
8856 "expected InvalidArgument(overflow), got {err:?}"
8857 );
8858 }
8859
8860 #[test]
8861 fn from_foreign_owner_keeps_allocation_alive() {
8862 // When `owner` is `Some`, dropping the Tensor must not free the backing
8863 // before the owner is also gone — the owner's Drop fires on last ref.
8864 use std::sync::atomic::{AtomicBool, Ordering};
8865 let flag = std::sync::Arc::new(AtomicBool::new(false));
8866 let flag2 = flag.clone();
8867 struct Guard(std::sync::Arc<AtomicBool>);
8868 impl Drop for Guard {
8869 fn drop(&mut self) {
8870 self.0.store(true, Ordering::SeqCst);
8871 }
8872 }
8873 let mut buf: Vec<u32> = vec![42u32; 4];
8874 let ptr = buf.as_mut_ptr();
8875 let owner: ForeignOwner = Box::new(Guard(flag2));
8876 let t = unsafe { Tensor::<u32>::from_foreign(ptr, &[4], Some(owner), None) }.unwrap();
8877 // Map co-owns the backing Arc; the owner must stay alive while the map lives.
8878 let m = t.map().unwrap();
8879 assert_eq!(m.as_slice()[0], 42);
8880 drop(t); // tensor dropped while map is still live
8881 assert!(
8882 !flag.load(Ordering::SeqCst),
8883 "owner must not drop while a map shares the backing"
8884 );
8885 drop(m);
8886 assert!(
8887 flag.load(Ordering::SeqCst),
8888 "owner Drop must fire when the last Arc reference is released"
8889 );
8890 }
8891}