# edgefirst-tensor Architecture
## Overview
`edgefirst-tensor` is the zero-copy tensor primitive that the rest of the
EdgeFirst HAL is built on. Its job is to give the higher-level crates a
uniform multi-dimensional array type that can be backed by any of four memory
sources — the platform-native GPU buffer, POSIX shared memory, the system
heap, or an OpenGL Pixel Buffer Object — without forcing the consumer to know
which backend is in use. A single `Tensor<T>` value is enough to feed CPU
code, hand a buffer to a GPU shader, share an inference output with another
process, or import a frame straight from a V4L2 camera.
"Platform-native GPU buffer" is one enum variant, `TensorMemory::DmaBuf`, with
three implementations behind it: a Linux DMA-heap DMA-BUF (`DmaTensor`), a
macOS/iOS `IOSurfaceRef` (`IoSurfaceTensor`), or an Android NDK
`AHardwareBuffer` (`AHardwareBufferTensor`). Callers ask for `DmaBuf` and get
whichever of the three the platform provides; see
[`TensorMemory::DmaBuf` is unified across platforms](#tensormemorydmabuf-is-unified-across-platforms).
## Module Map
| Module | Source | Responsibility |
|--------|--------|----------------|
| [`lib.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/lib.rs) | local | Public surface: `Tensor<T>`, `TensorTrait`, `TensorMemory`, `BufferIdentity`, `Region` + `view`/`batch` sub-regions, multi-plane composition (`from_planes`) |
| [`dma.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/dma.rs) | local | `DmaTensor<T>` — Linux DMA-BUF allocation via `dma-heap` |
| [`dmabuf.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/dmabuf.rs) | local | `mmap` + `DMA_BUF_IOCTL_SYNC` cache-coherency helpers used by `DmaMap` |
| [`iosurface.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/iosurface.rs) | local | `IoSurfaceTensor<T>` — macOS/iOS IOSurface allocation via raw FFI to the IOSurface + CoreFoundation frameworks (the macOS counterpart to `DmaTensor`) |
| [`ahardwarebuffer.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/ahardwarebuffer.rs) | local | `AHardwareBufferTensor<T>` — Android NDK AHardwareBuffer allocation, mapping, and import (the FFI shell; `cfg(target_os = "android")`) |
| [`ahardwarebuffer_layout.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/ahardwarebuffer_layout.rs) | local | Every pure Android decision the FFI shell must not drift on — lock usage, descriptor geometry, vendor classifier, identity interning. Deliberately cfg-free so it host-tests on any CI lane |
| [`shm.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/shm.rs) | local | `ShmTensor<T>` — POSIX shared memory backend |
| [`mem.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/mem.rs) | local | `MemTensor<T>` — heap-backed tensor with no syscalls |
| [`pbo.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/pbo.rs) | local | `PboTensor<T>` — wrapper around an OpenGL Pixel Buffer Object plus the `PboOps` trait the GL backend implements |
| [`cuda.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/cuda.rs) | local | `CudaHandle`, `CudaMap`, `CudaStream`, `CudaGlOps`; `dlopen`-resolved `libcudart` symbol table. See [Zero-copy CUDA tensor mapping](#zero-copy-cuda-tensor-mapping) |
| [`tensor_dyn/`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/tensor_dyn/) | local | `TensorDyn` — dtype-erased tensor, image metadata (`PixelFormat`, row stride, plane offset, multi-plane composition) |
| [`format.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/format.rs) | local | `PixelFormat`, `PixelLayout`, `ChromaLayout`, format/shape compatibility checks (`DType` itself lives in `lib.rs`) |
| [`colorimetry.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/colorimetry.rs) | local | `Colorimetry` and its parts (`ColorSpace`, `ColorRange`, `ColorTransfer`, `MatrixWeights`, `RangeScaling`) — the YUV↔RGB conversion contract carried as image metadata |
| [`preprocess.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/preprocess.rs) | local | Preprocessing values shared by the image crate (applies them) and the decoder crate (reads them from model metadata): `Normalization` + `NormalizationPreset`, `LetterboxPlacement`, `FitMode`/`Fit`, `Interpolation`, `PadSpace`, `PreprocessSpec`, the one `letterbox_rect` computation, `ContentRegion` and the `ContentRegionSource` trait, and `broadcast_rgb` for one-or-three-value fields. The enums are `#[non_exhaustive]`. Plain values and pure functions only |
| [`covguard.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/covguard.rs) | local | `SIGABRT` handler that flushes LLVM coverage before re-raising. Compiled only under `-Cinstrument-coverage` on Linux (the Vivante driver aborts at shutdown on imx8mp) |
| [`error.rs`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor/src/error.rs) | local | `Error`, `Result` |
## Key Types and Traits
- [`Tensor<T>`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/struct.Tensor.html) — generic strongly-typed tensor.
- [`TensorDyn`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/struct.TensorDyn.html) — dtype-erased tensor used by image processing and the C API.
- **`Region`** — `{ x, y, width, height }` in pixels; the single rectangle type, used by `view`, `Crop` (source sampling), and image geometry. Re-exported by the image and python crates.
- **`view(region)` / `batch(n)`** — owned, zero-allocation sub-region of a tensor sharing the parent's `BufferIdentity` (replaces the former byte-offset `subview`); `batch(n)` selects element *n* along the leading `N` dimension. Out-of-bounds → error, never clamp. The convert mechanics (GL `glViewport`, etc.) live in the image crate. See [Views and sub-regions](#views-and-sub-regions).
- [`TensorTrait`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/trait.TensorTrait.html) — **the unifying backend trait**: the common operations every memory backend implements (`shape`, `size`, `map`, `clone_fd`, `buffer_identity`, and the zero-copy sub-region `view`). `TensorStorage<T>` dispatches to the active backend's impl; `Tensor::subview`/`view`/`batch` route through `TensorTrait::view` so each backend's identity-sharing rule lives in one place.
- [`TensorMapTrait`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/trait.TensorMapTrait.html) — RAII map handle giving slice access (and ndarray views with the `ndarray` feature).
- [`TensorMemory`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/enum.TensorMemory.html) — request a specific backend at construction time.
- [`BufferIdentity`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/struct.BufferIdentity.html) — stable cache key (`id() -> u64`) plus a `Weak<()>` liveness guard for caches that need to detect stale entries.
- [`PlaneDescriptor`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/struct.PlaneDescriptor.html) — duplicated fd plus optional stride/offset, used for multi-plane DMA-BUF imports.
- [`PixelFormat`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/enum.PixelFormat.html) / [`DType`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/enum.DType.html) — image metadata attached via `set_format` / `with_format`.
- **`PlaneGeometry`** — `{ offset, stride, size }` for one plane, from `PixelFormat::plane_table(w, h, row_stride)`. Field-for-field with `TensorPlane.msg`. See [Shape, strides and planes](#shape-strides-and-planes).
- [`CpuAccess`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/enum.CpuAccess.html) — **required** on every image constructor: `None` / `Read` / `Write` / `ReadWrite`. Hardware access is always implied and never declared. See [CPU access declaration](#cpu-access-declaration-cpuaccess).
- [`ImageDesc`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/struct.ImageDesc.html) — the full-featured image request (builder style), and the only way to ask for [`Compression`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/enum.Compression.html). `Tensor::image` is the shorthand for the common case.
- [`PreprocessSpec`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/struct.PreprocessSpec.html) — a model's input preprocessing as plain values (normalization, fit, placement, pad and pad space, interpolation, size). The decoder produces it from model metadata and the image crate turns it into convert options; it lives here so neither crate depends on the other. [`ContentRegion`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/struct.ContentRegion.html) is where the image sits in a letterboxed destination, in pixels and normalized; [`letterbox_rect`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/fn.letterbox_rect.html) is the one computation of it (the inverse is `unletter_norm`), and `ContentRegionSource` lets the decoder take the image crate's `ConvertOptions` without naming it.
- [`Colorimetry`](https://docs.rs/edgefirst-tensor/latest/edgefirst_tensor/colorimetry/struct.Colorimetry.html) — the YUV↔RGB conversion contract (space, range, transfer, matrix weights) carried alongside the pixel format.
## Shape, strides and planes
Three things describe a tensor's layout, and conflating any two of them is the recurring bug in this area.
| Concept | Accessor | Answers |
|---|---|---|
| **Addressing grid** | `PixelFormat::addressing_shape(w, h)` | How a consumer *indexes* the image |
| **Allocation geometry** | `PixelFormat::allocation_shape(w, h)` | How large the *buffer* is |
| **Plane table** | `PixelFormat::plane_table(w, h, row_stride)` | *Where* each plane's bytes are |
For every non-subsampled multi-channel format the first two coincide, which is why NV12 is the case that catches a confusion between them:
| Format | `addressing_shape` | `allocation_shape` | Planes |
|---|---|---|---|
| `rgb8` packed | `[h, w, 3]` | `[h, w, 3]` | 1 |
| `mono8` | `[h, w]` | `[h, w, 1]` | 1 |
| planar RGB | `[3, h, w]` | `[3, h, w]` | 3 |
| `NV12` | `[h, w]` | `[h + ceil(h/2), w]` | 2 |
| bitstream (`h264`) | `[byte_length]`, dtype `U8` | same | 1 |
**Buffer extent comes from the plane table, never from the shape.** For a subsampled format `product(addressing_shape) * dtype_size` is *smaller* than the allocation: NV12 640×480 addresses a 640×480 luma grid inside a 460 800-byte buffer. Any validation asserting the two agree is correct only for single-plane packed formats and must be gated accordingly.
`allocation_shape` was named `image_shape` until the two meanings were separated. Every caller of the old name sized a buffer with it (`Tensor::image`, `configure_image`, `view()`, `import_image`, and the two PBO helpers), so one function called "the shape" made picking the wrong meaning the default — and picking the grid there undersizes every NV12 allocation by a third. There is deliberately no longer any function with that name.
**Strides are in bytes**, signed, on both `TensorDesc` and `PlaneGeometry`. Bytes rather than elements so a hardware row pitch is always representable: a pitch need not be a whole number of `dtype` elements, and a sub-byte dtype (int4 NPU weights) has no element size to divide by at all. The element-stride representation this replaced could not express such a pitch — it fell back to the packed stride and reported a pitch the buffer does not have, across an ABI boundary.
**One stride convention, one function.** `protocol::c_byte_strides(shape, esz, layout, row_stride)` is the only place byte strides are computed, and every surface reports its result: `TensorDesc` (`from_parts`), the blob's strides array, the C API's `ef_tensor_strides`, and the Python buffer protocol (`memoryview`, `np.asarray`, `TensorMap.numpy()`). A recorded row stride lands on the row dimension, and every dimension above it steps over padded rows:
| Layout | Shape | Strides with pitch `rs` |
|---|---|---|
| Packed | `[H, W, C]` | `[rs, C·esz, esz]` |
| Semi-planar | `[H·k, W]` | `[rs, esz]` |
| Planar | `[C, H, W]` | `[rs·H, rs, esz]` |
| Batched (any of the above, rank + 1) | `[N, …]` | `[image bytes, …]` — `N` steps over one padded image |
A planar image's plane stride follows the pitch, so plane `c` starts at `c·rs·H`, the offset the plane table and a strided `map()` use. A batched image is `N` images stacked at the padded pitch, which is the layout `batch(n)`, `map()` and `copy_to_flat` address. `width()`/`height()` describe one element of a batched tensor. A single image blob cannot describe a batch, so `blob::export` refuses one.
**Chroma extent is derived, never recomputed.** `plane_table` takes its chroma row count from `combined_plane_height`, which is exact for odd heights (`H + ceil(H/2)`, e.g. 483 → 725) where a hand-written `H/2` is off by a row.
### Agreement with the IPC schema
`addressing_shape` and `plane_table` are the producer side of `edgefirst_msgs/Tensor.msg` and `TensorPlane.msg` (EdgeFirst schemas, merged `71b56ed`). The golden values are pinned in `format.rs`'s tests with `(golden)` citations; changing one without changing the schemas fixture breaks the `blob -> msg -> blob` round-trip.
Two rules the schema validator enforces that an exporter here must satisfy:
- **All planes inline, or none.** A frame mixing transport modes has no coherent meaning — one `storage_kind`, `pid` and `fence_fd` cover every plane.
- **An inline plane** carries `size == data.len()`, `modifier == 0`, and empty `handle_bytes`.
`fence_fd` is an **acquire** fence and is per-tensor, never per-plane: it is a point on the producer's submission timeline, not a property of a plane's bytes. `EGL_ANDROID_native_fence_sync` (`crates/image/src/gl/native_fence.rs`) fences the command stream and takes no buffer argument, so a per-plane fence could only ever be the same fd duplicated.
`TensorDesc` deliberately still carries `allocation_shape`, not the grid: it has no plane table, so its `shape` is its only carrier of extent and `import_descriptor` reconstructs directly from it. The grid convention arrives with the blob format, where `planes[]` carry extent independently.
## The tensor blob
A size-prefixed, append-only serialization of a tensor: `blob::export(&TensorDyn, TransportMode)` and `blob::import(&[u8])`. It exists alongside `TensorDesc` because the descriptor is a fixed C struct with one handle and no plane table, so it cannot describe a multi-plane frame or travel by value.
**Region order is permanent from first publication.** A field may only be appended, never inserted. `required_mask` covers the one case appending cannot: an addition that changes how existing fields are *interpreted*, where an unaware consumer must refuse rather than proceed. `SUPPORTED_REQUIRED_MASK` is 0 today, so any bit set is a refusal — checked before any other field is trusted.
The fixed header is 72 bytes, widest-first so every scalar is naturally aligned. It carries the length of *every* variable region, which makes each region's offset a constant-time derivation rather than a sequential walk. That is the one thing CDR cannot do — it has no offset table — and it is why the blob is worth having next to the message rather than reusing it.
Variable tail, in the same order as the message's tail so the two can be read side by side: `shape[]: u64` · `strides[]: i64` (bytes) · `quant_scales[]: f32` · `quant_zero_points[]: i32` · five length-prefixed strings · plane records.
`strides_len` is carried rather than assumed, because empty strides are a *distinct* value — `Tensor.msg` defines them as "densely packed C-order", not as absent. Always writing `ndim` strides would break the reference-mode round-trip identity.
Each plane record is a 56-byte scalar block (`handle: i64`, then five `u64`) plus `handle_bytes` and `data`, padded to 8 so the next record's scalars stay aligned. 56 matches the schemas spec's independently-stated `TensorPlane` minimum.
### Ownership
> **Export borrows. Import dups. Nobody shares.**
Export writes the source tensor's own handles and performs no syscalls; they are valid only while it lives. Import dups every fd it retains, so the result is independent and the source may die. No keepalive protocol, and identical behaviour in-process, across `.so`s, and across processes.
### Transport modes
| Mode | Carried | Round-trip guarantee |
|---|---|---|
| **Reference** (`handle >= 0`) | handles, `pid`, `offset` | identity, handle included |
| **Inline** (`handle == -1`) | the bytes | content, geometry and every metadata field; the result is a **new allocation**, so handle identity is *not* preserved and must not be asserted |
Inline is not a fallback. `mem` and `pbo` have no shareable handle at all, and *any* storage kind must inline over a network, where an fd and a pid are meaningless. The choice is the application's; the HAL's job is to make it expressible, which is why `handle == -1` plus carried bytes is part of the format rather than a convention on top. Reference export of a `mem` tensor is refused rather than silently inlined — a caller who asked for a reference would otherwise get a copy without being told.
An inline export clears `pid` and `fence_fd` and zeroes each plane's `offset`: none survives the bytes travelling, and a non-zero `offset` could be misread as pointing into a handle that is not there. Import rebuilds plane positions from the format's plane table rather than trusting the wire.
### Parsing a blob is parsing hostile input
A blob arrives from another wheel, another process, or a network, so every count and length in its header is attacker-controlled and validated against the real buffer length before any allocation. `plane_count` is bounded by how many minimum-size records fit in `planes_bytes`, not by a constant — a forged count is a denial-of-service and the honest ceiling is the bytes present.
Two blanket tests walk every 4-byte word of the header and of a plane record through hostile values and assert the parser returns `Err` rather than panicking, since a panic on hostile input is itself a denial-of-service. Several individual bounds checks turn out to be redundant for *safety* — offsets increase monotonically, so a later check catches what an earlier one would have — but they are kept because they attribute the failure to the field that actually carried the bad value.
`std::str::from_utf8`, never `from_utf8_lossy`: a replacement character would hide malformed input rather than report it.
## Internal Architecture
### Backend dispatch
```mermaid
classDiagram
class TensorTrait~T~ {
<<trait>>
+shape() Vec~usize~
+size() usize
+map() TensorMap~T~
+clone_fd() Result~OwnedFd~
+buffer_identity() &BufferIdentity
}
class DmaTensor~T~ { Linux DMA-Heap }
class IoSurfaceTensor~T~ { macOS/iOS IOSurface }
class AHardwareBufferTensor~T~ { Android AHardwareBuffer }
class ShmTensor~T~ { POSIX shared memory }
class MemTensor~T~ { System heap }
class PboTensor~T~ { OpenGL PBO via PboOps }
TensorTrait <|.. DmaTensor
TensorTrait <|.. IoSurfaceTensor
TensorTrait <|.. AHardwareBufferTensor
TensorTrait <|.. ShmTensor
TensorTrait <|.. MemTensor
TensorTrait <|.. PboTensor
```
The first three are the per-platform faces of `TensorMemory::DmaBuf`; exactly one
of them is compiled in on any given target.
Each backend provides its own map type implementing `TensorMapTrait<T>`:
| Tensor | Map | Mechanism |
|--------|-----|-----------|
| `DmaTensor<T>` | `DmaMap<T>` | `mmap` + `DMA_BUF_IOCTL_SYNC` for cache coherency |
| `IoSurfaceTensor<T>` | `IoSurfaceMap<T>` | `IOSurfaceLock`/`Unlock`; `CpuAccess::Read` takes `kIOSurfaceLockReadOnly` and skips the unlock flush |
| `AHardwareBufferTensor<T>` | `AHardwareBufferMap<T>` | `AHardwareBuffer_lock` replaying the allocation's CPU usage values, masked to the requested direction |
| `ShmTensor<T>` | `ShmMap<T>` | `mmap`/`munmap` on the POSIX shared memory fd |
| `MemTensor<T>` | `MemMap<T>` | Direct raw pointer into `Vec<T>` (no syscall) |
| `PboTensor<T>` | `PboMap<T>` | GL thread `glMapBufferRange` / `glUnmapBuffer` via channel |
`TensorMap<T>` implements `Deref<Target=[T]>` and `DerefMut`. With the
`ndarray` feature enabled, `TensorMapTrait` also provides `view()` /
`view_mut()` returning ndarray `ArrayView` / `ArrayViewMut`.
### Static and dynamic backends
`TensorDyn`/`Tensor<T>` have two implementations behind one API (`src/tensor_dyn/`). `static_backend.rs` is the implementation; `dynamic_backend.rs` and `dynamic_tensor.rs` wrap an `ef_tensor*` handle and call `libedgefirst_tensor` through `edgefirst-tensor-ffi`. `derived.rs` holds every method expressible over the shared public surface, written once and compiled into both, which keeps the C ABI to the primitives.
The static backend is unconditional for Rust consumers. The dynamic one is selected by `--cfg edgefirst_tensor_dynamic` (`cfg(not(edgefirst_tensor_dynamic))` gates static-only code), which only the C-library and wheel builds pass, through the `.cargo/config.toml` in each of their crate directories. `edgefirst-tensor-ffi` is a `[target.'cfg(edgefirst_tensor_dynamic)'.dependencies]` entry, so a static build never resolves it. See the top-level [ARCHITECTURE.md § Tensor Backend Selection](https://github.com/EdgeFirstAI/hal/blob/main/ARCHITECTURE.md#tensor-backend-selection) for which artifact uses which backend and the guards that enforce it.
**The dynamic lens holds caches only.** Authoritative state lives in
`libedgefirst_tensor`. A field on dynamic `TensorDyn` may only be a cache
re-derived from the handle (`shape_cache`, `quantization_cache`, `identity`,
and Windows `descriptor_texture_handle`). `from_handle` initializes those
caches only.
| Field | Held | Now |
|---|---|---|
| `pbo` | the real `PboTensor<T>` | `TensorStorage::Pbo` in the library, via `ef_tensor_wrap_pbo` |
| `cuda` | a `CudaHandle` | `Tensor::cuda` in the library, via `ef_tensor_cuda_attach` |
| `multiplane_chroma` | the chroma plane's own handle | library `chroma` plane, via `ef_tensor_is_multiplane` / `ef_tensor_chroma` |
### Views and sub-regions
A **view** is a lightweight, zero-allocation sub-region of a tensor. It shares
the parent's buffer and **`BufferIdentity`** (no new allocation, no new GPU
import) and addresses a rectangular window of the parent's leading spatial frame:
```rust
/// The single rectangle type in the workspace. Pixel units of the leading
/// frame; byte addressing is derived from the parent's effective_row_stride().
pub struct Region { pub x: usize, pub y: usize, pub width: usize, pub height: usize }
impl Tensor<T> { // and identically on TensorDyn
/// Owned sub-region sharing self's BufferIdentity (an Arc clone — cheap,
/// once per view, never per pixel). Composes: a view of a view adds offsets.
pub fn view(&self, region: Region) -> Result<Self>;
/// Sugar for element `n` along the leading `N` dimension (N prepended to the
/// base layout — packed HWC or planar CHW). `batch(0)` on an N==1 tensor is
/// byte- and identity-equivalent to the whole tensor.
pub fn batch(&self, n: usize) -> Result<Self>;
}
```
`view`/`batch` are supported on **every** backend — `Mem`, `Shm`, Linux
DMA-BUF, macOS IOSurface, and PBO. Each shares its underlying resource (heap
`Arc`, cloned fd, retained `IOSurfaceRef`, or `Arc<PboHandle>`) and its
`BufferIdentity`, and carries the window's byte offset so a CPU map of the
sub-view reads the correct bytes (the IOSurface and PBO offset support is what
lets a GPU-backed decoder output be sub-viewed without the earlier
`InvalidOperation("subview only supported for Mem, Dma, and Shm tensors")`).
`view`/`batch` return an **owned** handle (not a borrow) so the value flows
unchanged through `convert(src, dst, …)`, the C ABI (`ef_tensor *`), and PyO3
(`#[pyclass]`) without a lifetime parameter rippling across three language
surfaces. An out-of-bounds region or `n ≥ N` returns an error
(`Error::RegionOutOfBounds` / `Error::BatchIndexOutOfBounds`) — `view`/`batch`
never clamp and never panic.
`view`/`batch` are the **only** sub-addressing primitive; they replace the
former byte-offset `subview`. `plane_offset` is **not** a sub-region mechanism —
it survives solely as an *import attribute* for genuine foreign or multi-plane
DMA-BUF imports (a non-zero start byte, or a chroma plane), where it participates
in the EGL cache key. It is never how a batch tile is selected.
`view`/`batch` are **raw tensor** concepts; the *mechanics* of consuming one for
GPU work live in the image crate — `convert()` lowers a **destination** view to a
`glViewport`/`glScissor` into the parent's one render target, and a **source**
view to a sampling rectangle, never re-keying the EGLImage (see
[`crates/image/ARCHITECTURE.md` § Batched preprocessing](https://github.com/EdgeFirstAI/hal/blob/main/crates/image/ARCHITECTURE.md#batched-preprocessing-building-a-batch-via-convert)).
For plain CPU access a consumer maps a view, or maps the parent whole and slices
the mapped `ArrayView` (the decoder reads batched model outputs this way). The
Python binding mirrors this with `numpy` + the buffer protocol; the C API exposes
first-class `ef_tensor_view` / `ef_tensor_batch` handles (it does not make
callers hand-roll pixel→byte math).
**Set/clear pairing, and the cfg-multiplexed-name hazard.** A backing whose
`map()` adds a storage-internal offset needs `set_plane_offset` to write that
offset **and** `set_format` (and `reshape`, where the storage does not zero it
itself) to clear it. A setter without its clear trades a lost window for a
*stale* one, which is harder to notice.
`TensorStorage::Dma` is a cfg-multiplexed **name**, not one type — `DmaTensor`
on Linux, `IoSurfaceTensor` on Apple, `AHardwareBufferTensor` on Android,
`D3d11TextureTensor` on Windows — so a `cfg`-gated `Dma` arm does not fail to
compile elsewhere, it silently becomes `_ => {}`. That is the mechanism behind
this whole issue class, and it must be checked wherever a per-backing arm is
added. `TensorStorage::Pbo` is one type on every target, so its arm is
ungated; saying so stops the next reader adding a `cfg` out of
pattern-matching. The three mutator matches are now exhaustive — no trailing
`_ => {}`, and no `allow(unreachable_patterns)` — so a new variant has to
state its behaviour rather than inherit silence.
### Memory selection logic
```mermaid
flowchart TD
Start[Tensor::new] --> Explicit{Explicit TensorMemory?}
Explicit -->|Yes| UseSpec[Use specified backend]
Explicit -->|No| CheckEnv{EDGEFIRST_TENSOR_FORCE_MEM=1?}
CheckEnv -->|Yes| UseMem[MemTensor]
CheckEnv -->|No| TryDMA[Try DmaTensor]
TryDMA --> DMASuccess{Success?}
DMASuccess -->|Yes| UseDMA[DmaTensor]
DMASuccess -->|No| TryShm[Try ShmTensor]
TryShm --> ShmSuccess{Success?}
ShmSuccess -->|Yes| UseShm[ShmTensor]
ShmSuccess -->|No| UseMem
style UseDMA fill:#90ee90
style UseShm fill:#87ceeb
style UseMem fill:#ffeb9c
```
The fallback chain is **DMA → SHM → Heap**. `EDGEFIRST_TENSOR_FORCE_MEM=1`
short-circuits the chain to `MemTensor`, primarily for unit tests on hosts
without DMA-heap permissions.
That chain describes `Tensor::new` (and `TensorDyn::new`). The **image**
constructors — `Tensor::image`, `image_with_stride`, `image_desc`, and their
`TensorDyn` equivalents — auto-select **DMA → Heap** and skip SHM entirely.
SHM buys an image nothing that Mem doesn't already give (it is not
GPU-importable, and Mem always succeeds), so it is reachable for an image only
by asking for `TensorMemory::Shm` explicitly.
### Import classification (`from_fd`, Linux)
`Tensor::from_fd()` is the mirror of the selection logic above: rather than
choosing a backend, it must *recognize* which backend a foreign fd already
belongs to. On Linux this is decided by **filesystem magic**, never by the
device number.
| `fstatfs` `f_type` | Constant | Backend |
|--------------------|----------|---------|
| `0x444d4142` | `DMA_BUF_MAGIC` | `DmaTensor` |
| `0x01021994` | `TMPFS_MAGIC` | `ShmTensor` (both `/dev/shm` and `memfd`) |
| anything else | — | `Error::UnknownBufferType(magic)` |
**Normalize `f_type` before comparing or reporting it.** Its width and
signedness vary by target — `__fsword_t` on Linux/gnu (`i64` on 64-bit,
`i32` on 32-bit), `c_int` on uclibc, `c_ulong` on musl, `c_uint` on s390x.
Where it is signed and 32 bits wide, widening it sign-extends any magic with
bit 31 set (hugetlbfs `0x958458f6`, btrfs `0x9123683e`, f2fs `0xf2f52010`),
which would make the value carried by `UnknownBufferType` impossible to look
up in `magic.h`. `fs_magic()` truncates back to `u32`, which is lossless for
every magic and is why the variant's payload is `u32` rather than a wider
signed type. The two magics we classify on are both below 2³¹, so the
DMA-vs-SHM decision itself is unaffected by signedness — only the reported
value was.
**Do not classify on `st_dev`.** `dma_buf` files do not live on a real
filesystem; they live on an internal kernel mount (`dma_buf_mnt`, created
with `kern_mount`) whose superblock takes its device number from
`get_anon_bdev()`. That allocator is an IDA shared by *every* anonymous
pseudo-filesystem — pipefs, sockfs, anon_inodefs, nsfs, bdev, tracefs —
handed out first-come-first-served during boot. The minor a DMA-BUF ends up
with is therefore an artifact of which pseudo-filesystems registered first
on that kernel build, and moves with kernel config, driver load order,
initramfs use, and kernel version. Nothing in the kernel documents it and it
is not part of any ABI. Measured values for a genuine DMA-BUF: **12** on x86
desktop, **8** on the ADIS Verdin. The kernel exports `DMA_BUF_MAGIC` for
exactly this purpose and exports nothing at all for the minor.
Both branches are identified **positively**, and an unrecognized filesystem
is an error rather than an assumption. This matters because the wrong branch
does not fail loudly: a DMA-BUF is mmap-able, so importing one as SHM yields
a perfectly functional tensor that merely isn't DMA — the loss only surfaces
much later, as `ImageProcessor::import_image` refusing a non-DMA plane. A
pipe fd is worse still, importing as a zero-length tensor. Guessing is the
failure mode; `UnknownBufferType` is the fix.
The portable alternative probe would be `dmabuf::phys()`
(`DMA_BUF_IOCTL_PHYS`), but that is an NXP vendor ioctl and fails on
mainline kernels. `fstatfs` is the portable answer and costs one syscall
with no side effects.
### The 64-byte row-stride invariant for DMA images
**Every DMA-backed image tensor carries a 64-byte-aligned row stride, in every
layout — packed, planar, and semi-planar.** Mali rejects an `eglCreateImage`
DMA-BUF import whose row pitch is not 64-byte aligned with `EGL_BAD_ALLOC`;
Vivante rejects it with `EGL_BAD_ACCESS`. Nothing about the failure points at
the stride, which is why this rule keeps getting broken and re-fixed.
The alignment has to hold at **both** of the places a stride is decided:
- **Allocation** (`Tensor::image` and friends) — the natural pitch is rounded
up with `next_multiple_of(64)` and the allocation is sized
`aligned_stride × total_rows`, not the shape product. The shape product
reflects only the logical width and under-allocates the padding.
- **Reconfiguration** (`configure_image`) — the easy site to miss, because it
is the recycle path rather than the allocate path. A pooled tensor being
re-pointed at a new geometry recomputes the aligned stride, reusing the prior
stride only when that stride is itself 64-aligned, wide enough for the new
layout, and still inside the allocation.
Host-only backings (Mem, Shm) keep the tight natural pitch for packed and
planar images so the many flat CPU consumers are unaffected; semi-planar takes
the aligned pitch on every backend because its chroma-plane offset arithmetic
assumes it. macOS is the exception that proves the rule: IOSurface picks its
own 64-aligned `bytesPerRow` and the tensor records whatever the surface
reports.
The failure mode is worth stating plainly, because it is easy to misread as a
refactor regression: an odd or merely non-multiple-of-16 width (321 → 1284
bytes, 322 → 1288, neither divisible by 64) converts fine on V3D and Tegra and
fails on imx95 and imx8mp.
### CPU access declaration (CpuAccess)
There is no usage/role enum. The allocation model assumes what is true for this SDK's pipelines: image buffers are produced and consumed by hardware (ISP, codec, GPU, NPU). Every image tensor allocates GPU-readable **and** GPU-writable (`GPU_SAMPLED_IMAGE | GPU_FRAMEBUFFER` on Android) — simultaneously a valid convert source, destination, and intermediate, and compression-eligible on every vendor. Hardware access needs no declaration: NPU/DSP consumption rides the dma-buf share itself (layout knowledge, not gralloc permission, is the contract), so GPU/NPU read/write is safely implied.
CPU involvement is the one thing the caller declares, as a `CpuAccess` parameter on every image constructor (`Tensor::image`, `TensorDyn::image`, `ImageProcessor::create_image`, the capi and Python surfaces, where it defaults to `ReadWrite`):
- `None` — hardware-only; on Android the allocation carries no CPU usage bits, making it eligible for gralloc's vendor tile compression; on Linux a DMA-BUF comes from the uncached CMA heap where there is one.
- `Read` — cached mapping; macOS takes the read-only IOSurface lock (`kIOSurfaceLockReadOnly`, skips the unlock flush); Linux dma-buf syncs the read direction only.
- `Write` — decode targets and CPU-drawn overlays; write-combined mapping where the platform supports it, no cache clean on unlock; on Linux a DMA-BUF comes from the uncached CMA heap where there is one.
- `ReadWrite` — both directions; the default (`CpuAccess::default()`, `ImageDesc::new`, the Python and C builders).
**Android and the `ReadWrite` default.** A `ReadWrite` gralloc allocation requests `CPU_READ_OFTEN | CPU_WRITE_OFTEN`, which may give linear, CPU-cached memory and forfeits vendor tiling and compression. A caller that never maps a buffer on the CPU should pass `CpuAccess::None` so gralloc can keep a tiled or compressed allocation. **Linux and uncached memory.** A DMA-BUF declared `None` or `Write` is allocated from `/dev/dma_heap/linux,cma-uncached` when the system has it, then from the cached CMA heap, then the system heap (`dma::HeapPolicy`, which `Tensor::image`, `ImageDesc` and `image_with_stride` build from the declaration); `Read` and `ReadWrite` keep the cached heaps. A device that maps a cached buffer (G2D, a compositor's blit, a GPU import) cleans and invalidates the CPU cache over all of it on every access; an uncached buffer needs no cache maintenance, and CPU writes to it are write-combined. CPU reads of uncached memory are slow, which is why the choice follows the declaration: the HAL enforces it on every CPU map, its own passes included, so such a buffer is never read by the CPU. `tests/write_only_destinations.rs` (image crate) checks that the CPU convert never reads a `Write` destination it maps, and `tests/uncached_device_import.rs` that GL and G2D render into and read from uncached images. The heap that served each allocation is logged at debug level.
`map()` is access-typed: `map_with(access)` is the one required trait method, with `map()` and `map_mut()` (`ReadWrite`), `map_read()` and `map_write()` provided. Because `map()` asks for `ReadWrite`, it fails on a `Read`- or `Write`-declared tensor: read such a tensor with `map_read()` and fill it with `map_for_write()`. Every `TensorMap` variant carries a `writable` flag and rejects mutable access through a read map with one uniform assert on all platforms.
**Write-only maps.** A write-only map does not invalidate the CPU cache, so it is valid only for a pass that overwrites the whole buffer. `TensorTrait::write_access` gives the direction such a pass maps with: the declaration plus `Write` for a whole tensor, `ReadWrite` for a view (`is_view`), whose rows share cache lines with bytes outside it. A view is a `view`/`batch` with a `ViewOrigin`, or a tensor rebuilt from a view's descriptor: the descriptor cannot carry the `ViewOrigin`, so it reports the `VIEW` flag bit and `import_descriptor` calls `mark_view` (C `ef_tensor_mark_view`, read back by `ef_tensor_is_view`). `TensorTrait::map_for_write` maps in that direction, and `map_for_partial_write` always maps `ReadWrite`.
The declaration covers **all** CPU access, the HAL's own included, and is **enforced**: a map, pin or sync bracket whose direction it does not cover fails with `Error::CpuAccessDenied { declared, requested }` before the backing is touched. The static backend checks in `Tensor::map_impl`, `pin_host` and `sync_for_cpu`/`sync_for_device`; the dynamic backend checks the same declaration (read through `ef_tensor_cpu_access`) before calling `ef_tensor_map`/`ef_tensor_sync_*`, so both raise the identical error, and `ef_tensor_map` itself returns `EACCES` for C callers. A descriptor reports the declaration in its `CPU_ACCESS_DECLARED`/`CPU_READ`/`CPU_WRITE` flag bits and `import_descriptor` narrows the alias to it (`restrict_cpu_access`, which never widens), so an alias refuses what its producer refuses. A Windows D3D11 texture import opens the texture with that declaration, so a `Write`-declared texture keeps its host-buffer path instead of gaining a staging texture. Producers that fill caller tensors on the CPU (the CPU image backend, codec decodes, `decode_semantic`) check up front and map with `map_for_write` or `map_for_partial_write`. Combining `CpuAccess != None` with a compression request is `InvalidArgument` at creation — the contradiction fails loudly at allocation, not at first map.
### PBO tensors and the WeakSender pattern
PBO tensors are different from the other three backends: they are not
allocated by the tensor crate at all. They are OpenGL Pixel Buffer Objects
managed by the GL thread inside `edgefirst-image`. The tensor crate provides
the `PboTensor` wrapper and the `PboOps` trait that the GL backend implements
to perform map / unmap / delete operations.
`PboTensor` holds an `Arc<dyn PboOps>` — a trait object the GL backend
implements to perform map / unmap / delete on the tensor's behalf. The
image crate's `GlPboOps` is the concrete implementation; it owns a
`WeakSender` to the GL thread's message channel. The weak-sender
ownership lives **inside the trait impl**, not in `PboTensor` itself,
so the tensor crate has no compile-time dependency on the image
crate's channel implementation. The `WeakSender` is the mechanism
that lets the GL thread exit cleanly when `ImageProcessor` is
dropped, even while PBO tensors are still alive; subsequent PBO
operations on orphaned tensors return `PboDisconnected`.
**A PBO is real library storage, reached through a callback channel.**
`libedgefirst_tensor` never makes a GL call. `ef_tensor_wrap_pbo` takes an
`ef_client_state` (`ctx` + `retain`/`release`) and the buffer's map/unmap
functions, and builds an ordinary `TensorStorage::Pbo` tensor over them — so
`view()`, `map()`, `set_plane_offset()` and `capacity_bytes()` behave like any
other backing while every GL operation calls back out to the owning process's
GL worker.
`PboOpsVtable` is that same triple plus the two op pointers, which is why the
cross-`cdylib` capsule path and the C constructor assemble the identical
thing. The vtable **borrows** its `ctx` (`Arc::as_ptr` on the `PboHandle`)
rather than owning a reference: it lives in a `OnceLock` inside that very
handle, so a reference taken there could never reach zero and every PBO would
leak its buffer. An importer takes its own reference through `state.retain`
instead, which is what lets a child outlive its parent.
**Ownership, once more, because reversing it double-frees.** `retain` and
`release` keep the *channel* alive. `PboHandle::Drop` stays the sole caller of
the real `delete_buffer`, and imported ops keep `delete_buffer` a no-op.
### BufferIdentity and EGL image caching
Every tensor allocation or import creates a fresh `BufferIdentity`
carrying:
- `id() -> u64` — monotonically increasing integer. Used by the image
crate's EGL image cache as the lookup key.
- `weak() -> Weak<()>` — goes dead when the owning tensor (and all
clones) are dropped, allowing caches to detect stale entries without
holding a strong reference.
The image processing backends key their EGL image cache on
`BufferIdentity.id()` (plus the import geometry — `width`/`height`/`row_stride`/
`format`) so that the **same tensor object** reused across frames hits the cache.
A sub-region of a tensor **shares the parent's `BufferIdentity`** and resolves
the parent's geometry, so the image backend keys every tile of one batched
destination on that parent — it imports a single EGLImage and selects the tile
with `glViewport`, never a per-offset import. `plane_offset` therefore addresses
only genuine offset-distinct *imports* (a foreign DMA-BUF starting at a non-zero
byte offset, or a multi-plane chroma plane), **not** batch tiling. Because the
key carries the geometry, reconfiguring a reused source buffer to a new size
re-keys to a fresh import rather than returning the previous frame's image.
The cache does **not** rescue a pipeline that
re-imports the same DMA-BUF every frame: each `ef_tensor_builder_wrap`
call mints a new `BufferIdentity` with a fresh
ID, so re-imports always miss. The contract is:
- Downstream caches (V4L2 / GStreamer adaptors) cache external
DMA-BUFs by stable `(inode, plane_offset)` and hold each
`ef_tensor *` alive across frames.
- That keeps `BufferIdentity.id()` constant for the same physical
buffer, which in turn keeps the in-HAL EGL image cache hitting.
**Android getId derivation.** `from_hardware_buffer` (and the allocation
paths, so export → re-import unifies) derive the identity from
`AHardwareBuffer_getId` — the system's stable 64-bit id for the
allocation (API 31+, resolved lazily via `dlsym` to keep the API-26 link
floor) — via `BufferIdentity::derived(IdentityKind::AHardwareBufferId, id)`.
Every re-wrap of the same physical buffer resolves to the same
`BufferIdentity` without any per-process cache: CameraX/ImageReader
pipelines that recycle a small buffer pool but re-wrap per frame hit the
EGLImage import cache instead of re-importing every frame, and two
independently-linked copies of this crate agree on the id without sharing
state. On API 26–30 the symbol is absent, so the identity is derived from
the `AHardwareBuffer*` pointer instead
(`IdentityKind::AHardwareBufferPtr`) — unique among live buffers in this
process, which is all the per-process cache needs.
See
[`crates/image/ARCHITECTURE.md`](https://github.com/EdgeFirstAI/hal/blob/main/crates/image/ARCHITECTURE.md)
for the EGL image cache implementation and
[the project ARCHITECTURE Appendix C](https://github.com/EdgeFirstAI/hal/blob/main/ARCHITECTURE.md#appendix-c-dma-buf-identity-and-tensor-caching)
for the full cross-cutting story.
### Tile compression metadata
Vendor GPUs store textures in proprietary compressed tile layouts that cut
memory bandwidth; gralloc selects them by usage bits (no public NDK flag
forces or queries the choice), and EGLImage sampling/rendering consumes
both layouts transparently:
| Vendor / GPU | Scheme | Engages when |
|---|---|---|
| Qualcomm Adreno | UBWC | GPU-only usage (no `CPU_*_OFTEN`) |
| Arm Mali / Immortalis | AFBC | same mechanism |
| Imagination PowerVR (Tensor G5+) | PVRIC | same mechanism |
| Samsung Xclipse | DCC | same mechanism |
| Apple | not exposed | linear IOSurface is the terminal GPU/ANE format |
Compression is image-format **metadata**, a sibling to `row_stride`,
`plane_offset`, quantization, and colorimetry: requested at creation
(`Compression::{Any, Scheme(..)}` on `ImageDesc`) and recorded as
allocated (`Tensor::compression() -> Option<CompressionScheme>`, one of
`Ubwc`/`Afbc`/`Pvric`/`Dcc`; both enums `#[non_exhaustive]`). Semantics:
- `Any` never fails for compression reasons: it records the scheme when
the allocation is eligible (Android, hardware-only, RGBA8888 u8/i8
initially, positively identified vendor) and otherwise resolves linear,
counted by `compression_fallback_count()` — loud and countable, the
`ConvertStats` philosophy.
- `Scheme(s)` is for consumers whose ABI names a layout (e.g. a QNN
context binary declaring `QNN_TENSOR_DATA_FORMAT_UBWC_RGBA8888`
inputs): allocation fails with `InvalidArgument` when the device's
scheme differs and `NotImplemented` on platforms without vendor tile
compression.
- The scheme is inferred from the `ro.hardware.egl` system property via
the host-tested `scheme_for_egl_vendor()` classifier (adreno → UBWC,
mali/immortalis → AFBC, powervr → PVRIC, xclipse → DCC;
emulation/angle/unknown → linear — there is deliberately no `Unknown`
variant; a scheme is recorded only on positive identification).
- `compression().is_some()` ⇒ the row-stride accessors describe no meaningful linear layout (tiles are opaque), and every CPU map is refused with `CpuAccessDenied`, since a compression request requires `CpuAccess::None`. `configure_image` **preserves** the recorded scheme (physical layout, unlike colorimetry); views and composed planes inherit it.
- `compression_support(format, dtype)` (capi
`ef_platform_compression_support`) reports whether a request can be
honored on this platform.
The recording is best knowledge: gralloc does not report its choice, so
the recorded scheme asserts "this allocation is compression-eligible on a
device whose vendor compresses this class", validated per device by the
Device Farm usage sweep and compressed-render cells (see
[`TESTING.md`](https://github.com/EdgeFirstAI/hal/blob/main/TESTING.md)).
Only Qualcomm's NPU stack consumes compressed input natively today
(QNN UBWC data formats, declared per tensor at context-binary
preparation); every other NPU stack takes linear zero-copy dma-buf —
which is exactly what `Compression: None` (the default) produces.
## Zero-copy CUDA Tensor Mapping
The CUDA surface maps the destination `ImageProcessor::convert()` writes —
a float PBO, a DMA-BUF tensor, or a D3D11 texture on Windows — to a CUDA
device pointer, enabling zero-copy inference with TensorRT and other CUDA
consumers. The cross-crate data-flow story lives in
[`ARCHITECTURE.md § Zero-copy CUDA tensor mapping`](https://github.com/EdgeFirstAI/hal/blob/main/ARCHITECTURE.md#zero-copy-cuda-tensor-mapping);
this section covers the tensor-crate implementation detail.
### Runtime symbol loading (`OnceLock` table)
All `libcudart` entry points are resolved once via `dlopen("libcudart.so")`
and stored in a process-global `OnceLock<CudaSymbols>`. If `libcudart` is
absent at runtime, the `OnceLock` stores `None` and every subsequent call
fast-fails to `None` / `false` without retrying the dlopen. There is no
link-time dependency and no compile-time feature gate.
`is_cuda_available() -> bool` returns `true` only if the symbol table was
populated successfully.
### `CudaHandle` — three backing variants
| Variant | Source | Device pointer lifetime |
|---------|--------|------------------------|
| `GlBuffer` | `cudaGraphicsGLRegisterBuffer` on a PBO | Per-map: valid between `cudaGraphicsMapResources` and `cudaGraphicsUnmapResources` |
| `ExternalMem` | `cudaImportExternalMemory(OpaqueFd)` on a DMA-BUF fd | Persistent: valid for the lifetime of the `CudaHandle` |
| `D3d11` (Windows) | `cudaImportExternalMemory(D3D11Resource)` on a texture's shared NT handle | Per-map refresh into a linear buffer owned by the handle |
All three expose the same `device_ptr() -> *mut c_void` / `len() -> usize`
interface to callers via `CudaMap`.
### `CudaMap` — RAII guard
`CudaMap` is the scoped guard returned by `Tensor::cuda_map()` and
`TensorDyn::cuda_map()`. Its semantics:
- **Construction** — calls `cudaGraphicsMapResources` (GL-buffer path) or
returns the persistent pointer (external-memory path). Routing to the
GL worker thread is handled by `CudaGlOps`.
- **`device_ptr() -> *mut c_void`** — the raw device pointer; valid for
the lifetime of the guard.
- **`len() -> usize`** — byte length of the mapped region.
- **`Drop`** — calls `cudaGraphicsUnmapResources` (GL-buffer path only),
releasing the PBO back to the GL pipeline. The PBO must not be
re-mapped while the guard is alive.
`CudaMap` is `Send` (the device pointer is usable from any thread via the
per-device CUDA primary context) but not `Sync` (two threads must not
concurrently access the raw pointer without external synchronization).
### `CudaGlOps` — GL-worker routing
`cudaGraphicsGLRegisterBuffer` and `cudaGraphicsMapResources` must run on
the GL-context thread. `CudaGlOps` is the trait the GL backend implements
to route those calls through the existing GL-thread message channel —
the same mechanism `PboOps` uses for PBO map/unmap/delete. Fast-fail
behavior: if `cuda_map()` is called on a tensor whose `CudaGlOps`
implementation reports that CUDA is absent or the GL thread has exited,
the call returns `None` immediately.
### DMA-BUF import (`ExternalMem` path)
For DMA-BUF-backed tensors, `cuda_map()` calls
`cudaImportExternalMemory` with `cudaExternalMemoryHandleTypeOpaqueFd`.
The DMA-BUF fd is `dup`'d before being handed to CUDA; CUDA takes
ownership of the dup'd fd on success (closing it when the
`CudaExternalMemory` handle is destroyed). This path is independent of
the GL thread.
### D3D11 texture import (`D3d11` path, Windows)
A `TensorMemory::DmaBuf` tensor on Windows is an `ID3D11Texture2D`, and CUDA
reads it through `cudaImportExternalMemory` with
`cudaExternalMemoryHandleTypeD3D11Resource` over the texture's shared NT
handle, plus the process device's fence imported as an external semaphore.
Like the DMA-BUF path this needs no GL thread. Unlike it, a texture is not
linear: the imported memory is a mipmapped array, so each map waits on the
recorded fence value and then copies level 0 into a linear device buffer the
handle owns.
That copy is what fixes the layout every consumer sees. The buffer is **tight
rows** of `width * bytes_per_texel` and `CudaMap::len()` is their sum;
`row_stride()` reports the D3D11 staging pitch a CPU map sees and does not
apply to the device pointer. `cuda_map_mut()` returns the same buffer for
writing and copies it back into the texture when the guard is released,
synchronizing before the release returns — the D3D11 arm is the only backing
where the two maps differ, because it is the only one whose buffer does not
alias the tensor. `cudaSetDevice` is set again before every copy and before
the release: it is per-thread state, and a consumer thread that last touched
another GPU would otherwise allocate on it.
The import is best-effort (`try_init_d3d11_cuda`): the WARP adapter has no
CUDA device at all, and a texture whose import fails is still a valid tensor
whose `cuda_map()` answers `None`.
### Drop order
Within a `PboTensor`'s internal state, the `CudaHandle` is owned by a
field that is declared before the PBO storage. Rust's field-drop order
(declaration order, reverse of construction) guarantees that
`cudaGraphicsUnregisterResource` runs before `glDeleteBuffers`, which is
the requirement from the CUDA–GL interop spec.
### Fast-fail on the non-GL path
`Tensor::cuda()` (the lower-level direct accessor, distinct from
`cuda_map()`) returns `None` if the tensor's backing storage is not
GPU-registered (e.g. `MemTensor`, `DmaTensor` without a CUDA import, or
`ShmTensor`). `cuda_map()` calls `cuda()` internally and propagates the
`None` without going to the GL thread.
## Performance Considerations
### When to use each backend
The backend choice trades allocation and mapping cost against who else can
touch the buffer without a copy.
1. **Heap (`MemTensor<T>`)** — fastest for pure CPU algorithms (resize,
filtering, format conversion). Allocation is a plain `Vec`, mapping is a
raw pointer, and neither involves a syscall. Pick this when no hardware
accelerator will see the buffer.
2. **Platform GPU buffer (`TensorMemory::DmaBuf`)** — costs more to allocate and
more to map (cache maintenance on Linux, a lock/unlock pair on macOS and
Android) and earns it back by being zero-copy for everything else in the
pipeline:
- The OpenGL backend, via `EGL_EXT_image_dma_buf_import` on Linux,
`EGL_ANGLE_iosurface_client_buffer` on macOS, and
`EGL_ANDROID_get_native_client_buffer` on Android
- G2D on NXP i.MX
- V4L2 capture and codec engines
- NPU delegates, which consume the shared buffer directly
3. **Shared memory (`ShmTensor<T>`)** — the slowest of the three for CPU work
and no use at all to hardware. It exists for one job: handing a buffer to
another process when the platform GPU buffer is unavailable, whether
because DMA-heap permissions are missing or because the OS has no
equivalent.
Short version: CPU-only work takes Heap, anything a GPU/NPU/codec will read
takes `Dma`, and cross-process sharing falls back to `Shm` only when `Dma`
cannot be had. The auto-select chains already encode this — see
[Memory selection logic](#memory-selection-logic).
### Multi-plane DMA-BUF support
Single-plane DMA-BUF buffers (one fd per buffer) are the common case: V4L2
single-planar capture, MIPI-CSI direct capture, and HAL-allocated buffers
all hit this path. The tensor crate also supports multi-plane formats
(NV12/NV16 from VPU and NeoISP, where Y and UV reside in separate
allocations) via `Tensor::from_planes(luma, chroma, PixelFormat::Nv12)`.
Each plane keeps its own DMA-BUF fd and per-plane stride / offset.
The C API exposes this through `ef_tensor_from_planes` in
[`edgefirst/tensor.h`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor-capi/include/edgefirst/tensor.h),
which takes two plane tensors and combines them via `from_planes`.
A downstream GStreamer source/transform element that wants to feed
multi-plane buffers into the HAL detects them via
`gst_buffer_n_memory() > 1` and extracts per-plane fds with
`gst_dmabuf_memory_get_fd()` on each `GstMemory` block, then passes
each fd into a separate `ef_tensor_builder_add_plane` call.
See
[`crates/image/ARCHITECTURE.md`](https://github.com/EdgeFirstAI/hal/blob/main/crates/image/ARCHITECTURE.md)
for the OpenGL-side multi-plane import path that consumes per-plane fds
via EGL attributes.
## Inter-Crate Interfaces
The tensor crate is the foundation of the data-plane crates — image,
decoder, the C-API leaves, and gpu-probe all depend on it. The tracker and bench
crates are independent of it (tracker operates on `DetectionBox` and
`nalgebra`; bench is a thin `serde_json` wrapper for benchmark IO):
| Consumer | Interface | Purpose |
|----------|-----------|---------|
| [`edgefirst-image`](https://github.com/EdgeFirstAI/hal/blob/main/crates/image/) | `Tensor<u8>`, `TensorDyn`, `PboOps` impl | Image processor input/output buffers, PBO management |
| [`edgefirst-decoder`](https://github.com/EdgeFirstAI/hal/blob/main/crates/decoder/) | `Tensor<T>`, `TensorMap` | Reading model output tensors |
| [`edgefirst-tensor-capi`](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor-capi/) | `from_fd`, `clone_fd`, `from_planes` | Tensor lifetime across the FFI boundary |
| [`gpu-probe`](https://github.com/EdgeFirstAI/hal/blob/main/crates/gpu-probe/) | `Tensor` allocation | Allocates the DMA-BUF round-trip buffer the probe verifies |
`BufferIdentity` is the in-HAL cache contract: the image crate's EGL
image cache keys on `buffer_identity().id()`, which is stable for the
lifetime of a tensor object. **Downstream import caches** (V4L2 /
libcamera / GStreamer adaptors) must not key on
`buffer_identity().id()` —
that id is regenerated on every HAL import. Downstream caches key on
the stable kernel `(inode, plane_offset)` of the external DMA-BUF and
then keep the resulting `ef_tensor *` alive across frames, which
keeps `buffer_identity().id()` stable and so keeps the image-side
cache hitting. See
[Appendix C: DMA-BUF Identity and Tensor Caching](https://github.com/EdgeFirstAI/hal/blob/main/ARCHITECTURE.md#appendix-c-dma-buf-identity-and-tensor-caching)
in the project ARCHITECTURE.md for the full two-layer story.
## Platform-Specific Notes
| Platform | DMA | SHM | Mem | PBO |
|----------|-----|-----|-----|-----|
| Linux (NXP i.MX, x86_64, aarch64) | Yes (DMA-BUF) | Yes | Yes | Yes (with OpenGL feature) |
| macOS / iOS | Yes (IOSurface) | Yes | Yes | No — the GL path renders into IOSurfaces |
| Android | Yes (AHardwareBuffer) | Yes | Yes | No — the GL path renders into AHardwareBuffers |
| Other Unix | No | Yes | Yes | No |
| Windows | Yes (D3D11 texture) | No | Yes | Yes (with OpenGL feature) |
### Every `TensorMemory` variant exists on every platform
`TensorMemory` is a namespace of storage codes, not a list of what the current build can allocate. Every variant is declared unconditionally — `Mem = 0`, `Shm = 1`, `DmaBuf = 2`, `IoSurface = 3`, `Pbo = 4`, `Cuda = 5` — through `ef_vocabulary!`, which makes the discriminants explicit and identical on every target. `Shm` used to be `#[cfg(unix)]`, which both shifted every variant declared after it on a non-unix build and made a cross-platform recording unreadable: a descriptor captured on Linux names `"dmabuf"`, and a macOS consumer has to be able to *name* that backing to report "cannot materialise here" rather than failing on an unrecognised code.
What *is* platform-dependent is availability, and that is a runtime question — `/dev/dma_heap` can exist while this process lacks permission on it — so it lives in `TensorMemory::is_available()` rather than in a `cfg`. Requesting a backing this build cannot serve returns a "not supported by this build" error, never a panic.
The enum is `#[non_exhaustive]`, like `DType`, `Compression` and `PixelFormat`: match it with a wildcard arm. The variant list is expected to keep growing — this change added two, and a real CUDA device backing is still on the way — and the attribute is what keeps a consumer outside this repo compiling across those additions. It protects downstream, not the HAL: inside `edgefirst-tensor` the matches stay exhaustive and compile-enforced, so a new variant is still a build error here. Outside it (the C mapping, the Python mapping) the substitute is an `all()`-driven test that fails the day a variant is added without a decision made there.
`IoSurface`, `Cuda` and `D3d11Texture` are defined as codes but no backend produces or accepts them as a tensor's `memory()` yet: macOS/iOS and Windows allocate and report `DmaBuf`, as before. The C ABI's `ef_storage_kind` matches the Rust discriminants (`EF_STORAGE_KIND_MEM=0`, `SHM=1`, `DMA_BUF=2`, `IO_SURFACE=3`, `PBO=4`, `CUDA=5`, `D3D11_TEXTURE=6`) — it is no longer a remapped outlier. A backend that starts reporting `IoSurface` is visible to compiled C callers as `EF_STORAGE_KIND_IO_SURFACE`.
The cross-package descriptor's `TensorDesc::kind` uses the same codes: `protocol::kind` is a re-export of the `TensorMemory` wire constants, so `kind::DMABUF == TensorMemory::DmaBuf.code() == 2`. `protocol::kind_of()` is the identity except for `DmaBuf`, which a producer reports as the concrete backing its handle names (`IOSURFACE` on macOS/iOS, `D3D11_TEXTURE` on Windows). `Mem` and `Shm` keep their own codes; an importer treats both as host memory (`TensorDesc::is_host`).
### `TensorMemory::DmaBuf` is unified across platforms
`TensorMemory::DmaBuf` is a single enum variant everywhere — same discriminant
value, same `EF_STORAGE_KIND_DMA_BUF=2` over the C ABI — but the underlying
storage type differs:
- **Linux**: `TensorStorage::Dma(DmaTensor<T>)` backed by a DMA-BUF fd
from `/dev/dma_heap/*`. `heap_allocate` tries the CMA heap and falls
back to the system heap, and records which one served the buffer as the
tensor's `Contiguity` (`Unknown` for `from_fd` imports, where the heap
cannot be known). A `contiguous` request, from
`ImageDesc::with_contiguous`, skips the fallback and fails with an
`IoError` naming the CMA heap.
- **macOS**: `TensorStorage::Dma(IoSurfaceTensor<T>)` backed by an
`IOSurfaceRef` from the IOSurface framework.
- **Windows**: `TensorStorage::Dma(D3d11TextureTensor<T>)` backed by an
`ID3D11Texture2D` on the process device (see below).
Match arms work identically on every platform because the
`TensorTrait` impl is the same shape (`new`, `map`, `name`, `memory`,
`buffer_identity`) regardless of which inner type is in play. The only
methods that genuinely differ are the platform-specific *export*
handles:
- **Linux**: `clone_fd()` produces a duplicated DMA-BUF fd suitable
for passing to V4L2, the GL backend's `EGL_EXT_image_dma_buf_import`
path, or any other Linux GPU API.
- **macOS**: `surface_id()` returns an `IOSurfaceID` (`u32`) stable
across processes — pair it with a Mach port or XPC handoff so the
receiver can call `IOSurfaceLookup(id)` to recover the
`IOSurfaceRef`. `surface_ref()` returns the borrowed pointer for
direct handoff to ANGLE's `EGL_ANGLE_iosurface_client_buffer`, to
CIImage, AVSampleBufferDisplayLayer, etc.
`from_iosurface()` (the import-side constructor) is macOS-only and
mirrors `from_fd()` — the receiver retains the surface for the
tensor's lifetime; the producer keeps its own retain count.
### Strided CPU maps of imported buffers
A tensor with a row stride maps `stride × rows` bytes, and every
`TensorMemory::DmaBuf` backing applies the same rule to it whether the buffer
was allocated here or imported: the map succeeds when the span fits the
buffer's real size and is refused otherwise. No backend refuses a strided map
because the buffer is imported, and the `is_imported` flags gate no map.
| Backing | Size the span is checked against | Error when it does not fit |
|---|---|---|
| DMA-BUF (Linux) | `buf_size − mmap_offset`; `buf_size` is `fstat`-derived for an imported fd | `InvalidOperation` |
| IOSurface (Apple) | `buf_size − view_offset` | `InsufficientCapacity` |
| AHardwareBuffer (Android) | `buf_size − view_offset` | `InsufficientCapacity` |
| D3D11 texture (Windows) | `backing_bytes − view_offset` (the staging copy at the driver pitch) | `InsufficientCapacity` |
The only difference is the error variant: the DMA-BUF arm predates the
others and keeps `InvalidOperation`. `tests/vivid_capture_import.rs` covers
the DMA-BUF rule with a real V4L2 capture buffer: vivid captures YUYV at a
padded `bytesperline`, the buffer is exported with `EXPBUF` and imported
through `Tensor::from_fd`, and every row of the strided map matches the
driver's own mapping.
### AHardwareBuffer (Android)
Android's `TensorMemory::DmaBuf` is
`TensorStorage::Dma(AHardwareBufferTensor<T>)`, backed by an NDK
`AHardwareBuffer` (stable C ABI since API 26 — the HAL's Android link
floor). It mirrors the IOSurface story with Android-specific rules:
- **Allocation** carries `GPU_SAMPLED_IMAGE | GPU_FRAMEBUFFER` always
(hardware access is implied) plus CPU usage bits derived from the
declared `CpuAccess` (`*_OFTEN` values). A hardware-only allocation
that gralloc refuses is retried once with CPU r/w bits — degrading to
linear-but-working, loudly, for budget-tier devices.
- **Geometry** per (format, dtype) mirrors `iosurface.rs::new_image`:
packed formats allocate `(width, height)`; planar F16 packs into
RGBA16F at `(W/4, C·H)`; packed RGB u8 rides RGBA8888 at `(W·3/4, H)`.
The format table (`image_format_and_bpe`) is the single source of
truth, host-tested in `ahardwarebuffer_layout.rs` — the module that
holds every pure decision the FFI layer must not drift on (lock usage,
descriptor geometry, vendor classifier, identity interning), because
no CI lane can *execute* Android code.
- **Mapping** replays the allocation's CPU usage VALUES masked to the
requested direction (the NDK lock contract: flags are enum values
within masks, not independent bits). A `CpuAccess::None` buffer
refuses deterministically — locking with undeclared bits is undefined
behaviour. Nested maps reuse the first lock only when its direction
covers the request.
- **Import** (`from_hardware_buffer`) derives the declared `CpuAccess`
from the imported allocation's usage bits, and identities intern on
`AHardwareBuffer_getId` (see *BufferIdentity and EGL image caching*).
- **Export**: `hardware_buffer_ptr()` returns the `AHardwareBuffer*` for
NNAPI/LiteRT/QNN registration; the GL backend imports via
`EGL_ANDROID_get_native_client_buffer` + EGLImage.
### D3D11 texture (Windows)
Windows' `TensorMemory::DmaBuf` is
`TensorStorage::Dma(D3d11TextureTensor<T>)`, backed by an `ID3D11Texture2D`
on one process-wide D3D11 device. The module is `src/d3d11/`:
- `d3d11/adapter.rs` enumerates DXGI and resolves `EDGEFIRST_D3D11_ADAPTER`
(alias `EDGEFIRST_ANGLE_ADAPTER`; the D3D11 name wins when both are set and
differ, logged once). `d3d11/device.rs` creates the device and its shared
`ID3D11Fence`, and rendezvouses with any other copy of this crate in the
process so two independently linked packages share one device.
`d3d11::use_external_device` installs a host's device instead, before
anything creates the HAL's.
- `d3d11_layout.rs` holds the pure `(format, dtype, width, height) ->
D3d11ImageLayout` table — the DXGI format, the texel grid and the GL
internal format the image crate imports it as. A format with no entry has
no texture layout and falls to PBO or `Mem` in the allocation chain.
- `d3d11/texture.rs` allocates, validates externally created textures, and
owns the staging texture a CPU map goes through. Its pitch becomes the
tensor's `row_stride`. Semi-planar layouts are widened to that pitch at
allocation so the plane's row stride and the sampled texture's width are
one number.
- `d3d11/geometry.rs` inverts a texture description back to `(width, height)`
for the descriptor and blob import paths.
Export is the texture pointer (`d3d11_texture()`, borrowed) or a duplicated
shared NT handle (`d3d11_shared_handle()`, owned by the caller);
`gpu_completion()` reports the fence handle and the value a GPU consumer waits
on, `gpu_write_value()` the value alone with no handle duplicated, and
`set_gpu_write()` records a consumer's own completion. CUDA reads the same
texture through the `D3d11` `CudaHandle` backing above.
### Cross-platform availability probes
| Probe | Returns true when |
|-------|-------------------|
| `is_dma_available()` | Linux DMA-BUF heap is mountable. False on every other OS. |
| `is_iosurface_available()` | macOS/iOS IOSurface framework is present. False on every other OS. |
| `is_ahardwarebuffer_available()` | Android AHardwareBuffer allocation succeeds. False on every other OS. |
| `is_gpu_buffer_available()` | Whichever of the three above applies to this target, and on Windows whether the process D3D11 device can be created — which it does by creating it, since the only honest answer is to try. The portable probe — prefer it when you only care whether `TensorMemory::DmaBuf` will succeed, not which mechanism backs it. |
| `is_shm_available()` | `shm_open` works. True on Linux and macOS, false on Windows. |
| `TensorMemory::is_available()` | Dispatches to the probe for that variant, so a caller holding a `TensorMemory` value can ask directly instead of picking a probe by hand. `IoSurface`, `Pbo` and `Cuda` answer `false` for now — the first two have no code of their own yet (macOS reports `DmaBuf`; PBOs come from `ImageProcessor::create_image`, whose GL context this crate cannot see), and the third has no backing at all. |
The Linux-specific `dma-heap` and macOS-specific `IOSurface` /
`CoreFoundation` framework links are gated by per-target sections in
`Cargo.toml`; each platform only pulls in the deps it needs.
## Cross-References
- Project architecture: [../../ARCHITECTURE.md](https://github.com/EdgeFirstAI/hal/blob/main/ARCHITECTURE.md)
- DMA-BUF identity story: [ARCHITECTURE.md#appendix-c-dma-buf-identity-and-tensor-caching](https://github.com/EdgeFirstAI/hal/blob/main/ARCHITECTURE.md#appendix-c-dma-buf-identity-and-tensor-caching)
- Image-side EGL cache and PBO dispatch: [../image/ARCHITECTURE.md](https://github.com/EdgeFirstAI/hal/blob/main/crates/image/ARCHITECTURE.md)
- C API tensor lifetime: [../tensor-capi/](https://github.com/EdgeFirstAI/hal/blob/main/crates/tensor-capi/)