edgefirst-tensor 0.35.0

Zero-copy tensor memory management with DMA, shared memory, and heap backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
# 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 |
| [`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.
- [`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`.

**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 must declare, as a required
`CpuAccess` parameter on every image constructor (`Tensor::image`,
`TensorDyn::image`, `ImageProcessor::create_image`, the capi and Python
surfaces):

- `None` (default in `ImageDesc`) — hardware-only; on Android the
  allocation carries no CPU usage bits, making it eligible for gralloc's
  vendor tile compression.
- `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; write-combined mapping where the platform
  supports it, no cache clean on unlock.
- `ReadWrite` — both directions (the pre-CpuAccess implicit behavior).

`map()` is access-typed: `map_with(access)` is the one required trait
method, with `map()` (= ReadWrite, the historical behavior), `map_read()`,
`map_write()`, and `map_mut()` provided. Every `TensorMap` variant carries
a `writable` flag and rejects mutable access through a read map with one
uniform assert on all platforms.

Mapping beyond the declaration is **best-effort, never silent**: the
platform may refuse (Android refuses deterministically — locking an
AHardwareBuffer with usage bits it was not allocated with is undefined
behaviour per the NDK contract, host-tested as `lock_usage_for` /
`LockDecision` in `ahardwarebuffer_layout.rs`) or may succeed on a slow
path; either way it logs once per buffer and increments the process-wide
`unplanned_cpu_access_count()`. Undeclared CPU access is a pipeline smell
the telemetry surfaces, not hides. 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 CPU maps follow the
  best-effort CpuAccess contract. `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` and `Cuda` are defined as codes but no backend produces or accepts them yet: macOS/iOS 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`) — 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`.

This is also *not* `protocol::kind`, which stays a separate, narrower vocabulary: `kind` records what a `TensorDesc`'s `ptr` and `handle` mean to an importer, so `Mem` and `Shm` both map to `kind::HOST`. `protocol::kind_of()` is the only legitimate bridge between the two — never an integer cast.

### `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/)